fix(fam-core): 登录密码不再硬编码兜底(fail-closed) + 会话有效期改 2 小时

之前 FAM_AUTH_USER/FAM_AUTH_PASS 没配置时会退回代码里写死的 ericwyuan/
iLoveJava5——这两个值跟 NAS SSH 密码是同一个,且这个登录页已经通过 frp 暴露在
公网上。.env 万一没配置好(这个项目里已经发生过好几次"忘了 source .env"的
情况),公网入口就会用一个和 SSH 密码相同、且写在源码里的已知密码兜底,双重
风险叠一起。

现在改成 fail-closed:FAM_AUTH_USER/FAM_AUTH_PASS 只要有一个没配置,直接拒绝
所有登录(打一条 ERROR 日志提醒去配 .env),不再有任何硬编码默认值。

会话有效期从 7 天改成 2 小时(按用户要求)。

新增 test_auth.py 11 个用例覆盖:未配置时 fail-closed、正确/错误凭据校验、
只配了一半也要拒绝、session 过期判定、白名单路径匹配。

已实测验证:当前 .env 里 FAM_AUTH_USER/PASS 仍是配置好的(还是 ericwyuan/
iLoveJava5,这个值本身没改——只是不再硬编码在代码里,运维如果想换成不同于
SSH 的密码,现在改 .env 就行,不用改代码),登录/拒绝错误密码都验证正常,
cookie Max-Age=7200 确认生效。
This commit is contained in:
ericwyuan
2026-08-23 07:56:33 +08:00
parent 9bba7b7e14
commit bdf561a415
3 changed files with 133 additions and 9 deletions

View File

@@ -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/cursormotion poll_enabled/pushed_total/heartbeat |
@@ -512,7 +512,7 @@ task_id=28930s 测试片段)全链路打通:推送 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 cookie7 天),重启 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

View File

@@ -3,9 +3,12 @@ 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 天有效;
- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS,由 NAS 的 start_core.sh
source .env 注入。2026-08-23 起不再提供硬编码默认值——这两个环境变量跟
NAS SSH 密码是同一个值,公网入口用同一串密码兜底、还写死在代码里是双重
风险;.env 没配置好这两个变量时直接拒绝所有登录fail closed而不是
悄悄退回一个大家都知道的密码。
- 登录态:进程内 token 表 + HttpOnly cookiefam_session2 小时有效;
重启进程后需重新登录(可接受,见 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

106
fam-core/tests/test_auth.py Normal file
View File

@@ -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