From 9c51c65e62575d8bc2aee79bc55dba053dd1df81 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sat, 22 Aug 2026 14:19:54 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20FAM-Core=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=A0=A1=E9=AA=8C=20+=20frp=20=E5=A4=96=E7=BD=91?= =?UTF-8?q?=E6=9A=B4=E9=9C=B2=208000=20-=20=E6=96=B0=E5=A2=9E=20auth=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97(POST=20/api/login=20=E6=A0=A1=E9=AA=8C=20FAM?= =?UTF-8?q?=5FAUTH=5FUSER/PASS=20=E9=BB=98=E8=AE=A4=20ericwyuan/iLoveJava5?= =?UTF-8?q?,=20HttpOnly=20cookie=207=E5=A4=A9,=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E5=BD=95=20302=20/login,=20/api/*=20?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E5=BD=95=20401;=20=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95:=20/login=20/health=20/api/ss/webhook=20/assets/*);?= =?UTF-8?q?=20=E5=86=85=E7=BD=AE=E6=B7=B1=E8=89=B2=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=A1=B5(SPA=20=E9=9B=B6=E6=94=B9=E5=8A=A8);=20frpc.toml=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20fam-core=20=E4=BB=A3=E7=90=86=20=E5=A4=96?= =?UTF-8?q?=E7=BD=918000->NAS8000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fam-core/src/fam_core/app.py | 5 + fam-core/src/fam_core/auth.py | 204 ++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 fam-core/src/fam_core/auth.py diff --git a/fam-core/src/fam_core/app.py b/fam-core/src/fam_core/app.py index d6e8ab3..62d835c 100644 --- a/fam-core/src/fam_core/app.py +++ b/fam-core/src/fam_core/app.py @@ -24,6 +24,7 @@ from .img_proxy import img_bp from .motion_bp import motion_bp from .ui_api import ui_bp from .static_app import static_bp +from .auth import auth_bp, init_auth logger = setup_logger('fam-core.app') @@ -36,8 +37,12 @@ app.register_blueprint(member_bp) app.register_blueprint(img_bp) app.register_blueprint(motion_bp) app.register_blueprint(ui_bp) +app.register_blueprint(auth_bp) # 登录页 /api/login 等(static_bp 通配之前) app.register_blueprint(static_bp) +# 登录校验全局拦截(页面未登录 302 /login;/api/* 未登录 401) +init_auth(app) + # 健康检查 @app.route('/health', methods=['GET']) def health(): diff --git a/fam-core/src/fam_core/auth.py b/fam-core/src/fam_core/auth.py new file mode 100644 index 0000000..cfe2b64 --- /dev/null +++ b/fam-core/src/fam_core/auth.py @@ -0,0 +1,204 @@ +""" +Auth - FAM-Core 登录校验(2026-08-22 新增) + +背景:NAS :8000(fam-core + FAM-UI)通过 frp 暴露到外网后,需要先登录才能访问。 + +- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS(默认 ericwyuan / iLoveJava5), + 由 NAS 的 start_core.sh source .env 注入。 +- 登录态:进程内 token 表 + HttpOnly cookie(fam_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 = """ + + + + +家庭监控 · 登录 + + + +
+ +
请输入账号密码登录后访问
+
+ + + + + +
+
+
Sentinel Home AI
+
+ + + +"""