diff --git a/README.md b/README.md index bccf54b..da23a55 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ chat_history 独立表(问答上下文摘要留存) | 端点 | 方法 | 说明 | |------|------|------| | `/health` | GET | 服务健康(**免登录**) | -| `/api/login` | POST | 登录:`{"username","password"}` → 校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(默认 `ericwyuan`/`iLoveJava5`,NAS `.env` 可改)→ 种 HttpOnly cookie `fam_session`(7 天) | +| `/api/login` | POST | 登录:`{"username","password"}` → 校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,无硬编码默认值,未配置则拒绝所有登录)→ 种 HttpOnly cookie `fam_session`(2 小时) | | `/api/logout` | POST | 退出登录(清 cookie) | | `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false}`(**免登录**) | | `/api/status` | GET | Oracle-Sync + MotionNotifier 状态(sync running/cursor;motion poll_enabled/pushed_total/heartbeat) | @@ -512,7 +512,7 @@ task_id=289(30s 测试片段)全链路打通:推送 5.7MB → Edge 分析 > - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core(本系统)** > - 外网入口 `http://129.146.203.203:8000`,**需登录**(见 §5.2 `/api/login`) > -> **登录校验(2026-08-22)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env`,默认 `ericwyuan`/`iLoveJava5`);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie(7 天),重启 fam-core 需重新登录。 +> **登录校验(2026-08-22 新增,2026-08-23 改为 fail-closed)**:FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`(内置深色登录页,SPA 零改动),`/api/*` 未登录 401;凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`(NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie(**2 小时**),过期或重启 fam-core 需重新登录。 > Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge`(`--strip-components=1` 解临时目录再 cp,避免动 data/venv/gdrive_videos)。 diff --git a/fam-core/src/fam_core/auth.py b/fam-core/src/fam_core/auth.py index cfe2b64..83228ca 100644 --- a/fam-core/src/fam_core/auth.py +++ b/fam-core/src/fam_core/auth.py @@ -3,9 +3,12 @@ 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 天有效; +- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS,由 NAS 的 start_core.sh + source .env 注入。2026-08-23 起不再提供硬编码默认值——这两个环境变量跟 + NAS SSH 密码是同一个值,公网入口用同一串密码兜底、还写死在代码里是双重 + 风险;.env 没配置好这两个变量时,直接拒绝所有登录(fail closed),而不是 + 悄悄退回一个大家都知道的密码。 +- 登录态:进程内 token 表 + HttpOnly cookie(fam_session),2 小时有效; 重启进程后需重新登录(可接受,见 config/auth 说明)。 - 拦截策略(app.before_request 全局生效): * 页面路径未登录 -> 302 重定向 /login @@ -23,16 +26,31 @@ import time from flask import (Blueprint, Response, jsonify, make_response, redirect, request) +from .logger import setup_logger + +logger = setup_logger('fam-core.auth') + auth_bp = Blueprint('auth', __name__) -_SESSION_TTL = 7 * 24 * 3600 # cookie 有效期 7 天 +_SESSION_TTL = 2 * 3600 # cookie 有效期 2 小时 _sessions = {} # token -> 过期时间戳(进程内;重启需重新登录) +_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告日志,别刷屏 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') + """账号密码校验。凭据必须从环境变量读(.env 注入),不提供硬编码默认值—— + 这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码" + 这种兜底,宁可直接拒绝所有登录(fail closed),等运维发现并配置好 .env。""" + global _warned_unconfigured + user = os.environ.get('FAM_AUTH_USER', '') + pwd = os.environ.get('FAM_AUTH_PASS', '') + if not user or not pwd: + if not _warned_unconfigured: + logger.error( + "FAM_AUTH_USER/FAM_AUTH_PASS 未配置,拒绝所有登录——" + "请在 .env 里设置这两个环境变量后重启 fam-core") + _warned_unconfigured = True + return False return (username or '') == user and (password or '') == pwd diff --git a/fam-core/tests/test_auth.py b/fam-core/tests/test_auth.py new file mode 100644 index 0000000..d526868 --- /dev/null +++ b/fam-core/tests/test_auth.py @@ -0,0 +1,106 @@ +import os +import time + +import pytest + +from fam_core import auth + + +@pytest.fixture(autouse=True) +def _clean_env_and_sessions(monkeypatch): + """每个用例前清掉环境变量和进程内 session 表,用例之间不互相污染。""" + monkeypatch.delenv('FAM_AUTH_USER', raising=False) + monkeypatch.delenv('FAM_AUTH_PASS', raising=False) + auth._sessions.clear() + auth._warned_unconfigured = False + yield + auth._sessions.clear() + + +def test_check_credential_fails_closed_when_unconfigured(): + """核心诉求: .env 没配置 FAM_AUTH_USER/FAM_AUTH_PASS 时必须拒绝所有登录, + 不能退回任何硬编码默认账号密码(这两个变量跟 NAS SSH 密码是同一个值,公网 + 入口不能有"没配置就用已知密码兜底"这种行为)。""" + assert auth._check_credential('ericwyuan', 'iLoveJava5') is False + assert auth._check_credential('anything', 'anything') is False + assert auth._check_credential('', '') is False + + +def test_check_credential_succeeds_with_matching_configured_values(monkeypatch): + monkeypatch.setenv('FAM_AUTH_USER', 'testuser') + monkeypatch.setenv('FAM_AUTH_PASS', 'testpass') + assert auth._check_credential('testuser', 'testpass') is True + + +def test_check_credential_rejects_wrong_password_when_configured(monkeypatch): + monkeypatch.setenv('FAM_AUTH_USER', 'testuser') + monkeypatch.setenv('FAM_AUTH_PASS', 'testpass') + assert auth._check_credential('testuser', 'wrongpass') is False + assert auth._check_credential('wronguser', 'testpass') is False + + +def test_check_credential_fails_closed_when_only_one_var_set(monkeypatch): + """只配了一半(比如账号忘配密码)也要 fail closed,不能退化成"密码随便"。""" + monkeypatch.setenv('FAM_AUTH_USER', 'testuser') + assert auth._check_credential('testuser', '') is False + assert auth._check_credential('testuser', 'anything') is False + + +def test_session_ttl_is_two_hours(): + assert auth._SESSION_TTL == 2 * 3600 + + +def test_is_authed_true_within_ttl(): + tok = 'sometoken' + auth._sessions[tok] = time.time() + 3600 + + class _FakeRequest: + cookies = {'fam_session': tok} + monkeypatch_request = auth.request + try: + auth.request = _FakeRequest() + assert auth.is_authed() is True + finally: + auth.request = monkeypatch_request + + +def test_is_authed_false_after_expiry(): + tok = 'expiredtoken' + auth._sessions[tok] = time.time() - 1 # 已过期 + + class _FakeRequest: + cookies = {'fam_session': tok} + monkeypatch_request = auth.request + try: + auth.request = _FakeRequest() + assert auth.is_authed() is False + # 过期后应该从 session 表里清掉,不留垃圾 + assert tok not in auth._sessions + finally: + auth.request = monkeypatch_request + + +def test_is_authed_false_without_cookie(): + class _FakeRequest: + cookies = {} + monkeypatch_request = auth.request + try: + auth.request = _FakeRequest() + assert auth.is_authed() is False + finally: + auth.request = monkeypatch_request + + +def test_whitelist_exact_paths(): + for path in ('/login', '/api/login', '/api/logout', '/api/auth/check', + '/health', '/favicon.ico', '/api/ss/webhook'): + assert auth._is_whitelisted(path) is True + + +def test_whitelist_assets_prefix(): + assert auth._is_whitelisted('/assets/index-abc123.js') is True + + +def test_whitelist_rejects_protected_paths(): + for path in ('/', '/api/ui/people', '/api/chat/ask', '/api/ss/status'): + assert auth._is_whitelisted(path) is False