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:
50
CHANGELOG.md
50
CHANGELOG.md
@ -6,3 +6,53 @@
|
||||
- 初始化仓库:README、CHANGELOG、DECISION_LOG
|
||||
- 每日运维总结机制确立
|
||||
- 首份总结:2026-06-14 服务器功能变动总结
|
||||
|
||||
## v1.1.0 (2026-06-15)
|
||||
|
||||
### Added
|
||||
- — Cookie session auth proxy 完整源码和 systemd 服务文件
|
||||
- — Python HTTPServer,bcrypt 密码验证,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天过期,HttpOnly,SameSite=Lax,Domain=.xybkwd.top
|
||||
- 密码验证不变(bcrypt),token 签名防篡改(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 HTTPServer,bcrypt 密码验证,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天过期,HttpOnly,SameSite=Lax,Domain=.xybkwd.top
|
||||
- 密码验证不变(bcrypt),token 签名防篡改(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
369
auth_proxy/auth_proxy.py
Executable 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()
|
||||
14
auth_proxy/auth_proxy.service
Normal file
14
auth_proxy/auth_proxy.service
Normal 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
|
||||
Reference in New Issue
Block a user