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 = """ + +
+ + +