auth: 从 Caddy basic_auth 迁移为应用内表单登录(cookie会话)兼容微信内置浏览器
This commit is contained in:
@@ -17,9 +17,6 @@ hyonglin.top {
|
|||||||
path /xinquandashboard/*
|
path /xinquandashboard/*
|
||||||
}
|
}
|
||||||
handle @dq {
|
handle @dq {
|
||||||
basic_auth {
|
|
||||||
xqzk $2a$14$GrTqs4WiYHepvOq/0CnOU.Zq2.cA4B2FcilgGIv/6Jbi9BZX/8rNK
|
|
||||||
}
|
|
||||||
uri strip_prefix /xinquandashboard
|
uri strip_prefix /xinquandashboard
|
||||||
reverse_proxy xinquan:8000
|
reverse_proxy xinquan:8000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ services:
|
|||||||
build: ./xinquan
|
build: ./xinquan
|
||||||
container_name: xinquan
|
container_name: xinquan
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- XQ_USER=xqzk
|
||||||
|
- XQ_PASS=66668888
|
||||||
|
- XQ_SECRET=bb8335b291300ea338373feea74af915e3346aedd52d7ac2fb66721a16b394e8
|
||||||
volumes:
|
volumes:
|
||||||
- ./xinquan_data:/data
|
- ./xinquan_data:/data
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
+166
-11
@@ -16,6 +16,8 @@
|
|||||||
鉴权:由 Caddy basic_auth 在网关卡负责,本应用不鉴权。
|
鉴权:由 Caddy basic_auth 在网关卡负责,本应用不鉴权。
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -35,6 +37,33 @@ ALLOWED_IMG_EXT = {".png": "image/png", ".jpg": "image/jpeg",
|
|||||||
".jpeg": "image/jpeg", ".gif": "image/gif",
|
".jpeg": "image/jpeg", ".gif": "image/gif",
|
||||||
".webp": "image/webp", ".svg": "image/svg+xml"}
|
".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():
|
def _ensure_dirs():
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
@@ -110,6 +139,17 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|||||||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
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)}
|
.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}
|
.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{padding-top:8px}
|
||||||
.page .title{font-size:26px;font-weight:800;letter-spacing:-.02em;margin-bottom:4px}
|
.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}
|
.page .meta{color:var(--muted);font-size:13px;margin-bottom:18px}
|
||||||
@@ -148,6 +188,7 @@ INDEX_HTML = r"""<!DOCTYPE html>
|
|||||||
const BASE = (()=>{const p=location.pathname;const i=p.indexOf('/xinquandashboard');
|
const BASE = (()=>{const p=location.pathname;const i=p.indexOf('/xinquandashboard');
|
||||||
return i>=0?p.slice(0,i+'/xinquandashboard'.length):'';})();
|
return i>=0?p.slice(0,i+'/xinquandashboard'.length):'';})();
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
|
const AUTH = __AUTH__;
|
||||||
|
|
||||||
function api(path, opts){
|
function api(path, opts){
|
||||||
return fetch(BASE+path, opts).then(async r=>{
|
return fetch(BASE+path, opts).then(async r=>{
|
||||||
@@ -171,7 +212,8 @@ async function loadPages(){
|
|||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
function renderList(){
|
function renderList(){
|
||||||
$('#topActions').innerHTML = `<button class="primary" onclick="newPage()">+ 新建页面</button>`;
|
$('#topActions').innerHTML = `<button class="primary" onclick="newPage()">+ 新建页面</button>
|
||||||
|
<button onclick="logout()">👤 退出</button>`;
|
||||||
const a=$('#app');
|
const a=$('#app');
|
||||||
if(!state.pages.length){ a.innerHTML=`<div class="empty">还没有内容。<br><button class="primary" onclick="newPage()">创建第一个页面</button></div>`; return; }
|
if(!state.pages.length){ a.innerHTML=`<div class="empty">还没有内容。<br><button class="primary" onclick="newPage()">创建第一个页面</button></div>`; return; }
|
||||||
a.innerHTML = `<div class="grid">${state.pages.map(p=>`
|
a.innerHTML = `<div class="grid">${state.pages.map(p=>`
|
||||||
@@ -253,8 +295,45 @@ async function delPage(id){
|
|||||||
}
|
}
|
||||||
function cancelEdit(){ loadPages(); }
|
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=`<div class="login">
|
||||||
|
<div class="lg">XQ</div>
|
||||||
|
<h2>Xinquan Dashboard</h2>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
<input id="u" type="text" placeholder="用户名" autocomplete="username">
|
||||||
|
<input id="p" type="password" placeholder="密码" autocomplete="current-password">
|
||||||
|
<button id="go">登 录</button></div>`;
|
||||||
|
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='<button onclick="logout()">👤 退出登录</button>';
|
||||||
|
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='<button onclick="logout()">👤 退出登录</button>';
|
||||||
|
loadPages();
|
||||||
|
}
|
||||||
if(umarked){ document.addEventListener('DOMContentLoaded',()=>toast('Markdown 渲染库加载失败,回退为纯文本')); }
|
if(umarked){ document.addEventListener('DOMContentLoaded',()=>toast('Markdown 渲染库加载失败,回退为纯文本')); }
|
||||||
loadPages();
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -306,33 +385,107 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
path = "/" + path
|
path = "/" + path
|
||||||
return 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):
|
def do_GET(self):
|
||||||
path = self._split()
|
path = self._split()
|
||||||
if path == "/media/health":
|
if path == "/health":
|
||||||
return self._json({"ok": True})
|
return self._json({"ok": True})
|
||||||
if path.startswith("/media/"):
|
user = self._user()
|
||||||
return self._media(path[len("/media/"):])
|
if path.startswith("/api/"):
|
||||||
|
if not user:
|
||||||
|
return self._auth_denied()
|
||||||
if path.startswith("/api/pages/"):
|
if path.startswith("/api/pages/"):
|
||||||
pid = path[len("/api/pages/"):]
|
pid = path[len("/api/pages/"):]
|
||||||
if pid:
|
return self._get_page(pid) if pid else self._json({"error": "missing id"}, 400)
|
||||||
return self._get_page(pid)
|
|
||||||
return self._json({"error": "missing id"}, 400)
|
|
||||||
if path == "/api/pages":
|
if path == "/api/pages":
|
||||||
return self._list_pages()
|
return self._list_pages()
|
||||||
return self._send(200, self._index_html(), "text/html; charset=utf-8")
|
return self._json({"error": "not found"}, 404)
|
||||||
|
if path.startswith("/media/"):
|
||||||
|
if not user:
|
||||||
|
return self._auth_denied()
|
||||||
|
return self._media(path[len("/media/"):])
|
||||||
|
# 其余(/, /login, SPA 内部路由):总是渲染外壳;未登录则显示登录表单
|
||||||
|
return self._send(200, self._index_html(bool(user)),
|
||||||
|
"text/html; charset=utf-8")
|
||||||
|
|
||||||
def _index_html(self):
|
def _index_html(self, authed=True):
|
||||||
return INDEX_HTML.replace("__MARKED__", BOOTSTRAP).encode("utf-8")
|
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):
|
def do_POST(self):
|
||||||
path = self._split()
|
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":
|
if path == "/api/pages":
|
||||||
return self._create_page()
|
return self._create_page()
|
||||||
if path == "/api/upload":
|
if path == "/api/upload":
|
||||||
return self._upload()
|
return self._upload()
|
||||||
self._json({"error": "not found"}, 404)
|
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):
|
def do_PUT(self):
|
||||||
|
if not self._user():
|
||||||
|
return self._auth_denied()
|
||||||
path = self._split()
|
path = self._split()
|
||||||
if path.startswith("/api/pages/"):
|
if path.startswith("/api/pages/"):
|
||||||
pid = path[len("/api/pages/"):]
|
pid = path[len("/api/pages/"):]
|
||||||
@@ -341,6 +494,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json({"error": "not found"}, 404)
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
def do_DELETE(self):
|
def do_DELETE(self):
|
||||||
|
if not self._user():
|
||||||
|
return self._auth_denied()
|
||||||
path = self._split()
|
path = self._split()
|
||||||
if path.startswith("/api/pages/"):
|
if path.startswith("/api/pages/"):
|
||||||
pid = path[len("/api/pages/"):]
|
pid = path[len("/api/pages/"):]
|
||||||
|
|||||||
Reference in New Issue
Block a user