[功能] FAM-Core 登录校验 + frp 外网暴露 8000 - 新增 auth 模块(POST /api/login 校验 FAM_AUTH_USER/PASS 默认 ericwyuan/iLoveJava5, HttpOnly cookie 7天, 页面未登录 302 /login, /api/* 未登录 401; 白名单: /login /health /api/ss/webhook /assets/*); 内置深色登录页(SPA 零改动); frpc.toml 增加 fam-core 代理 外网8000->NAS8000

This commit is contained in:
ericwyuan
2026-08-22 14:19:54 +08:00
parent 1dd9df34c2
commit 9c51c65e62
2 changed files with 209 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ from .img_proxy import img_bp
from .motion_bp import motion_bp from .motion_bp import motion_bp
from .ui_api import ui_bp from .ui_api import ui_bp
from .static_app import static_bp from .static_app import static_bp
from .auth import auth_bp, init_auth
logger = setup_logger('fam-core.app') logger = setup_logger('fam-core.app')
@@ -36,8 +37,12 @@ app.register_blueprint(member_bp)
app.register_blueprint(img_bp) app.register_blueprint(img_bp)
app.register_blueprint(motion_bp) app.register_blueprint(motion_bp)
app.register_blueprint(ui_bp) app.register_blueprint(ui_bp)
app.register_blueprint(auth_bp) # 登录页 /api/login 等static_bp 通配之前)
app.register_blueprint(static_bp) app.register_blueprint(static_bp)
# 登录校验全局拦截(页面未登录 302 /login/api/* 未登录 401
init_auth(app)
# 健康检查 # 健康检查
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health(): def health():

View File

@@ -0,0 +1,204 @@
"""
Auth - FAM-Core 登录校验2026-08-22 新增)
背景NAS :8000fam-core + FAM-UI通过 frp 暴露到外网后,需要先登录才能访问。
- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS默认 ericwyuan / iLoveJava5
由 NAS 的 start_core.sh source .env 注入。
- 登录态:进程内 token 表 + HttpOnly cookiefam_session默认 7 天有效;
重启进程后需重新登录(可接受,见 config/auth 说明)。
- 拦截策略app.before_request 全局生效):
* 页面路径未登录 -> 302 重定向 /login
* /api/* 未登录 -> 401 JSON
- 白名单免登录:
* /login /api/login /api/logout /api/auth/check —— 登录流程本身
* /health —— 内部健康检查
* /api/ss/webhook —— Surveillance Station 推送无法携带登录态
* /assets/*、/favicon.ico —— SPA 静态资源
"""
import os
import secrets
import time
from flask import (Blueprint, Response, jsonify, make_response,
redirect, request)
auth_bp = Blueprint('auth', __name__)
_SESSION_TTL = 7 * 24 * 3600 # cookie 有效期 7 天
_sessions = {} # token -> 过期时间戳(进程内;重启需重新登录)
def _check_credential(username: str, password: str) -> bool:
"""账号密码校验。凭据从环境变量读(.env 注入),未设置用默认值。"""
user = os.environ.get('FAM_AUTH_USER', 'ericwyuan')
pwd = os.environ.get('FAM_AUTH_PASS', 'iLoveJava5')
return (username or '') == user and (password or '') == pwd
def is_authed() -> bool:
tok = request.cookies.get('fam_session')
if not tok:
return False
exp = _sessions.get(tok)
if not exp:
return False
if time.time() > exp:
_sessions.pop(tok, None)
return False
return True
# ---------------------------------------------------------------------------
# 白名单(免登录)
# ---------------------------------------------------------------------------
_WHITELIST_EXACT = {
'/login', '/api/login', '/api/logout', '/api/auth/check',
'/health', '/favicon.ico',
'/api/ss/webhook', # SS 推送(无登录态,必须放行)
}
_WHITELIST_PREFIX = ('/assets/',)
def _is_whitelisted(path: str) -> bool:
if path in _WHITELIST_EXACT:
return True
return any(path.startswith(p) for p in _WHITELIST_PREFIX)
def init_auth(app):
"""注册全局登录拦截。需在注册完所有蓝图后调用。"""
@app.before_request
def _guard():
path = request.path
if _is_whitelisted(path):
return None
if is_authed():
return None
if path.startswith('/api/'):
return jsonify({"error": "未登录", "code": 401}), 401
return redirect('/login')
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@auth_bp.route('/login', methods=['GET'])
def login_page():
if is_authed():
return redirect('/')
return Response(_LOGIN_HTML, mimetype='text/html')
@auth_bp.route('/api/login', methods=['POST'])
def login():
data = request.get_json(silent=True) or request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not _check_credential(username, password):
return jsonify({"error": "账号或密码错误"}), 401
tok = secrets.token_hex(24)
_sessions[tok] = time.time() + _SESSION_TTL
resp = make_response(jsonify({"ok": True}))
resp.set_cookie('fam_session', tok, max_age=_SESSION_TTL,
httponly=True, samesite='Lax', path='/')
return resp
@auth_bp.route('/api/logout', methods=['POST'])
def logout():
tok = request.cookies.get('fam_session')
if tok:
_sessions.pop(tok, None)
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie('fam_session', path='/')
return resp
@auth_bp.route('/api/auth/check', methods=['GET'])
def check():
return jsonify({"authed": is_authed()})
# ---------------------------------------------------------------------------
# 登录页(内嵌 HTML深色风格与 fam-ui 一致;前端 SPA 无需改动)
# ---------------------------------------------------------------------------
_LOGIN_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>家庭监控 · 登录</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
min-height:100vh; display:flex; align-items:center; justify-content:center;
background: radial-gradient(1200px 600px at 20% -10%, #1e293b 0%, #0b1120 55%, #0b1120 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color:#e2e8f0;
}
.card {
width:360px; padding:40px 36px 32px; border-radius:16px;
background: rgba(15,23,42,.75); border:1px solid rgba(148,163,184,.18);
box-shadow: 0 24px 64px rgba(0,0,0,.45);
}
.logo { font-size:26px; font-weight:700; letter-spacing:.5px; margin-bottom:6px; }
.logo span { color:#38bdf8; }
.sub { font-size:13px; color:#94a3b8; margin-bottom:28px; }
label { display:block; font-size:12px; color:#94a3b8; margin:14px 0 6px; }
input {
width:100%; padding:10px 12px; border-radius:8px; font-size:14px; color:#e2e8f0;
background:#0f172a; border:1px solid #334155; outline:none; transition:border .15s;
}
input:focus { border-color:#38bdf8; }
button {
width:100%; margin-top:24px; padding:11px; border:none; border-radius:8px;
font-size:14px; font-weight:600; color:#0b1120; background:#38bdf8; cursor:pointer;
transition:background .15s, transform .05s;
}
button:hover { background:#0ea5e9; }
button:active { transform: scale(.98); }
.err { margin-top:14px; font-size:13px; color:#f87171; min-height:18px; text-align:center; }
.foot { margin-top:22px; text-align:center; font-size:11px; color:#475569; }
</style>
</head>
<body>
<div class="card">
<div class="logo">家庭监控 <span>FAM</span></div>
<div class="sub">请输入账号密码登录后访问</div>
<form id="f">
<label for="u">账号</label>
<input id="u" name="username" autocomplete="username" autofocus required>
<label for="p">密码</label>
<input id="p" name="password" type="password" autocomplete="current-password" required>
<button type="submit">登 录</button>
</form>
<div class="err" id="err"></div>
<div class="foot">Sentinel Home AI</div>
</div>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('err');
err.textContent = '';
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('u').value.trim(),
password: document.getElementById('p').value
})
});
if (r.ok) { location.href = '/'; return; }
const d = await r.json().catch(() => ({}));
err.textContent = d.error || ('登录失败(' + r.status + '');
} catch (ex) {
err.textContent = '网络错误,请重试';
}
});
</script>
</body>
</html>
"""