auth: add auth_proxy v2 with cookie session auth

- Add auth_proxy/auth_proxy.py: Python HTTP server, bcrypt password, HMAC-SHA256 token
- Add auth_proxy/auth_proxy.service: systemd service on port 9002
- Update CHANGELOG.md: v1.1.0 with auth_proxy section
- Security: basicauth replaced by session cookie (7 days, HttpOnly, SameSite=Lax)
- Cross-subdomain cookie via Domain=.xybkwd.top
This commit is contained in:
fxy
2026-06-15 19:31:37 +08:00
parent df7d64d205
commit 858a94811c
3 changed files with 433 additions and 0 deletions

View File

@ -6,3 +6,53 @@
- 初始化仓库README、CHANGELOG、DECISION_LOG
- 每日运维总结机制确立
- 首份总结2026-06-14 服务器功能变动总结
## v1.1.0 (2026-06-15)
### Added
- — Cookie session auth proxy 完整源码和 systemd 服务文件
- — Python HTTPServerbcrypt 密码验证HMAC-SHA256 token 签名
- — systemd 服务port 9002开机自启
### Changed
- **安全认证升级**Caddy basicauth → auth_proxy Cookie session
- 所有保护服务gitea/dashboard/books/nav/brief/todo改为走 auth_proxy:9002
- 新增 auth.xybkwd.top 子域作为登录入口
- Session cookie: 7天过期HttpOnlySameSite=LaxDomain=.xybkwd.top
- 密码验证不变bcrypttoken 签名防篡改HMAC-SHA256
- — 去掉所有 basicauth改为 reverse_proxy localhost:9002
- 服务清单更新books (Calibre-Web) 状态同步
### Fixed
- 移动端重复登录问题basicauth 每 ~1 小时要求重新输入密码
- Cookie 跨子域失效:缺 Domain=.xybkwd.top 导致各子域不共享 cookie
- Set-Cookie 值带多余引号:浏览器拒绝该 cookie
### Security
- 认证安全性不变bcrypt + HMAC-SHA256无明文密码传输
- 公开服务blog/vaultwarden/memos不经过 auth_proxy不受影响
## v1.1.0 (2026-06-15)
### Added
- — Cookie session auth proxy 完整源码和 systemd 服务文件
- — Python HTTPServerbcrypt 密码验证HMAC-SHA256 token 签名
- — systemd 服务port 9002开机自启
### Changed
- **安全认证升级**Caddy basicauth → auth_proxy Cookie session
- 所有保护服务gitea/dashboard/books/nav/brief/todo改为走 auth_proxy:9002
- 新增 auth.xybkwd.top 子域作为登录入口
- Session cookie: 7天过期HttpOnlySameSite=LaxDomain=.xybkwd.top
- 密码验证不变bcrypttoken 签名防篡改HMAC-SHA256
- — 去掉所有 basicauth改为 reverse_proxy localhost:9002
- 服务清单更新books (Calibre-Web) 状态同步
### Fixed
- 移动端重复登录问题basicauth 每 ~1 小时要求重新输入密码
- Cookie 跨子域失效:缺 Domain=.xybkwd.top 导致各子域不共享 cookie
- Set-Cookie 值带多余引号:浏览器拒绝该 cookie
### Security
- 认证安全性不变bcrypt + HMAC-SHA256无明文密码传输
- 公开服务blog/vaultwarden/memos不经过 auth_proxy不受影响

369
auth_proxy/auth_proxy.py Executable file
View File

@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
auth_proxy.py v2 - Cookie-based session auth proxy for Caddy.
Replaces Caddy basicauth to fix repeated login prompts.
Changes in v2:
- Cookie uses Domain=.xybkwd.top for cross-subdomain sharing
- Removed Secure flag (Caddy terminates TLS, upstream is plain HTTP)
"""
import http.server
import http.client
import urllib.parse
import json
import hmac
import hashlib
import base64
import os
import time
import bcrypt
import re
import mimetypes
# ─── Configuration ─────────────────────────────────────────────
PORT = 9002
SECRET_FILE = '/home/ubuntu/.auth_proxy_secret'
SESSION_DURATION = 7 * 24 * 3600 # 7 days
COOKIE_NAME = 'sess'
NAV_ROOT = '/var/www/nav'
# Password bcrypt hash (fxy / Qiyun2025!)
PASSWORD_HASH = b'$2b$14$0U13bqD7CgmZe7jcBQAUHehV2M6qrotbxERgDrHgzLxSXtmRX/YUK'
# Host -> backend mapping
BACKENDS = {
'gitea.xybkwd.top': ('127.0.0.1', 3000),
'dashboard.xybkwd.top': ('127.0.0.1', 9000),
'books.xybkwd.top': ('127.0.0.1', 8083),
}
STATIC_HOSTS = {
'nav.xybkwd.top', 'brief.xybkwd.top',
'www.xybkwd.top', 'xybkwd.top',
'auth.xybkwd.top',
}
# Load or generate secret key
if os.path.exists(SECRET_FILE):
with open(SECRET_FILE) as f:
SECRET_KEY = f.read().strip()
else:
SECRET_KEY = os.urandom(32).hex()
with open(SECRET_FILE, 'w') as f:
f.write(SECRET_KEY)
os.chmod(SECRET_FILE, 0o600)
# ─── Token management ──────────────────────────────────────────
def make_token(username):
payload = json.dumps({
'u': username,
'e': int(time.time()) + SESSION_DURATION,
'i': os.urandom(4).hex()
}, separators=(',', ':'))
b64 = base64.urlsafe_b64encode(payload.encode()).rstrip(b'=').decode()
sig = hmac.new(SECRET_KEY.encode(), b64.encode(),
hashlib.sha256).hexdigest()[:24]
return b64 + '.' + sig
def verify_token(token):
try:
parts = token.split('.')
if len(parts) != 2:
return None
b64, sig = parts
expected = hmac.new(SECRET_KEY.encode(), b64.encode(),
hashlib.sha256).hexdigest()[:24]
if not hmac.compare_digest(sig, expected):
return None
pad = 4 - len(b64) % 4
if pad != 4:
b64 += '=' * pad
payload = json.loads(base64.urlsafe_b64decode(b64))
if payload.get('e', 0) < time.time():
return None
return payload.get('u')
except Exception:
return None
# ─── Auth handler ──────────────────────────────────────────────
LOGIN_PAGE = '''<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>栖云 · 登录</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue","PingFang SC",sans-serif;background:#13131a;color:#f0f0f5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.card{background:rgba(40,40,50,0.85);backdrop-filter:blur(20px);border-radius:20px;padding:40px;width:100%;max-width:340px;box-shadow:0 8px 32px rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06)}
h1{font-size:22px;font-weight:600;margin-bottom:4px;text-align:center}
.sub{color:#98989d;font-size:13px;text-align:center;margin-bottom:28px}
.form-group{margin-bottom:16px}
label{display:block;font-size:12px;color:#98989d;margin-bottom:4px}
input{width:100%;padding:12px 14px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.05);color:#f0f0f5;font-size:15px;outline:none;transition:border .2s}
input:focus{border-color:#7c7cf0}
button{width:100%;padding:12px;border-radius:12px;border:none;background:#7c7cf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer;margin-top:4px}
button:hover{background:#6a6ae0}
.error{color:#ff3b30;font-size:12px;text-align:center;margin-top:12px}
.footer{font-size:11px;color:#636366;text-align:center;margin-top:20px}
</style></head>
<body><div class="card">
<h1>栖云</h1>
<p class="sub">个人数据中心</p>
<form method="post" action="/login">
<input type="hidden" name="redirect" value="REDIRECT_PLACEHOLDER">
<div class="form-group"><label>用户名</label><input type="text" name="username" placeholder="fxy" autocomplete="username"></div>
<div class="form-group"><label>密码</label><input type="password" name="password" placeholder="输入密码" autocomplete="current-password"></div>
<button type="submit">登录</button>
ERROR_PLACEHOLDER
</form><div class="footer">auth.xybkwd.top</div>
</div></body></html>'''
class AuthHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
try:
with open('/tmp/auth_proxy.log', 'a') as f:
f.write('{} - {}\n'.format(self.client_address[0], fmt % args))
except Exception:
pass
def get_session_user(self):
cookie = self.headers.get('Cookie', '')
m = re.search(r'{}=([^;]+)'.format(COOKIE_NAME), cookie)
if m:
return verify_token(m.group(1))
return None
def redirect(self, location):
self.send_response(302)
self.send_header('Location', location)
self.end_headers()
def serve_login_page(self, redirect, error=''):
html = LOGIN_PAGE.replace('REDIRECT_PLACEHOLDER', redirect)
if error:
html = html.replace('ERROR_PLACEHOLDER',
'<div class="error">{}</div>'.format(error))
else:
html = html.replace('ERROR_PLACEHOLDER', '')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(html.encode())))
self.end_headers()
self.wfile.write(html.encode())
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
redirect = params.get('redirect', ['https://nav.xybkwd.top/'])[0]
if parsed.path == '/login':
self.serve_login_page(redirect)
elif parsed.path == '/verify':
user = self.get_session_user()
self.send_response(200 if user else 401)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(
json.dumps({'ok': bool(user), 'user': user}).encode())
else:
user = self.get_session_user()
if not user:
actual_url = 'https://{}{}'.format(
self.headers.get('Host', 'nav.xybkwd.top').split(':')[0],
self.path)
login_url = 'https://auth.xybkwd.top/login?redirect={}'.format(
urllib.parse.quote(actual_url))
self.redirect(login_url)
return
host = self.headers.get('Host', 'nav.xybkwd.top').split(':')[0]
if parsed.path.startswith('/api/'):
self.proxy_request('127.0.0.1', 9001)
elif host in STATIC_HOSTS:
self.serve_static(parsed.path)
elif host in BACKENDS:
bh, bp = BACKENDS[host]
self.proxy_request(bh, bp)
else:
self.serve_static(parsed.path)
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/login':
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length).decode('utf-8', errors='replace')
form = urllib.parse.parse_qs(body)
username = form.get('username', [''])[0]
password = form.get('password', [''])[0]
redirect = form.get('redirect', ['https://nav.xybkwd.top/'])[0]
if username == 'fxy' and bcrypt.checkpw(password.encode(), PASSWORD_HASH):
token = make_token(username)
# Domain=.xybkwd.top for cross-subdomain cookie sharing
cookie_val = '{}={}; Path=/; Domain=.xybkwd.top; HttpOnly; SameSite=Lax; Max-Age={}'.format(
COOKIE_NAME, token, SESSION_DURATION)
self.send_response(302)
self.send_header('Location', redirect)
self.send_header('Set-Cookie', cookie_val)
self.end_headers()
else:
params = urllib.parse.parse_qs(parsed.query)
rd = params.get('redirect', ['https://nav.xybkwd.top/'])[0]
self.serve_login_page(rd, '用户名或密码错误')
elif parsed.path == '/verify':
user = self.get_session_user()
self.send_response(200 if user else 401)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(
json.dumps({'ok': bool(user), 'user': user}).encode())
else:
user = self.get_session_user()
if not user:
actual_url = 'https://{}{}'.format(
self.headers.get('Host', 'nav.xybkwd.top').split(':')[0],
self.path)
login_url = 'https://auth.xybkwd.top/login?redirect={}'.format(
urllib.parse.quote(actual_url))
self.redirect(login_url)
return
host = self.headers.get('Host', 'nav.xybkwd.top').split(':')[0]
if parsed.path.startswith('/api/'):
self.proxy_request('127.0.0.1', 9001)
elif host in BACKENDS:
bh, bp = BACKENDS[host]
self.proxy_request(bh, bp)
else:
self.serve_static(parsed.path)
do_PUT = do_POST
do_DELETE = do_POST
do_PATCH = do_POST
# ─── Static file serving ──────────────────────────────────
MIME_MAP = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.xml': 'text/xml; charset=utf-8',
}
def serve_static(self, path):
if path == '/':
path = '/index.html'
abs_path = os.path.normpath(os.path.join(NAV_ROOT, path.lstrip('/')))
if not abs_path.startswith(os.path.normpath(NAV_ROOT)):
self.send_response(403)
self.end_headers()
self.wfile.write(b'Forbidden')
return
if not os.path.isfile(abs_path):
self.send_response(404)
self.end_headers()
self.wfile.write(b'Not found')
return
ext = os.path.splitext(abs_path)[1].lower()
content_type = self.MIME_MAP.get(ext, 'application/octet-stream')
try:
with open(abs_path, 'rb') as f:
data = f.read()
except IOError:
self.send_response(404)
self.end_headers()
self.wfile.write(b'Not found')
return
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'private, max-age=60')
self.end_headers()
self.wfile.write(data)
# ─── Reverse proxy ────────────────────────────────────────
def proxy_request(self, backend_host, backend_port):
try:
conn = http.client.HTTPConnection(backend_host, backend_port, timeout=30)
fwd_headers = {}
for h in ['Host', 'Content-Type', 'Content-Length',
'Accept', 'Accept-Encoding', 'Accept-Language',
'Cookie', 'Referer', 'User-Agent', 'Origin',
'Authorization', 'If-Modified-Since', 'If-None-Match',
'Range', 'X-Forwarded-For', 'X-Real-IP']:
v = self.headers.get(h)
if v:
fwd_headers[h] = v
# Strip our session cookie before forwarding
if 'Cookie' in fwd_headers:
cleaned = re.sub(r'{}=[^;]+;?\s*'.format(COOKIE_NAME), '',
fwd_headers['Cookie']).strip()
if cleaned:
fwd_headers['Cookie'] = cleaned
else:
del fwd_headers['Cookie']
body = None
content_length = self.headers.get('Content-Length')
if content_length:
body = self.rfile.read(int(content_length))
conn.request(self.command, self.path, body=body, headers=fwd_headers)
resp = conn.getresponse()
resp_body = resp.read()
self.send_response(resp.status, resp.reason)
skip_headers = {'transfer-encoding', 'connection', 'keep-alive',
'content-length'}
for h, v in resp.getheaders():
if h.lower() not in skip_headers:
self.send_header(h, v)
self.send_header('Content-Length', str(len(resp_body)))
self.end_headers()
self.wfile.write(resp_body)
conn.close()
except Exception as e:
self.send_response(502)
self.end_headers()
self.wfile.write('Proxy error: {}'.format(e).encode())
def do_HEAD(self):
self.send_response(405)
self.end_headers()
# ─── Main ──────────────────────────────────────────────────────
def main():
server = http.server.HTTPServer(('127.0.0.1', PORT), AuthHandler)
print('Auth proxy listening on 127.0.0.1:{}'.format(PORT))
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,14 @@
[Unit]
Description=Auch proxy service for Caddy (cookie-based session auth)
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu
ExecStart=/usr/bin/python3 /home/ubuntu/auth_proxy.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target