Files
sentinel-home-ai/fam-edge/tests/test_auth.py
ericwyuan dc284f4bf5 feat(fam-edge): 统一登录从 NAS fam-core 迁入,回调失败不再对跳死循环
登录入口原先跑在 NAS 的 fam-core 里,NAS 或 frp 隧道一挂,
smart-camera.zichuan.xyz/login 直接 502——连登录页都打不开。登录是入口,
不该依赖家里那台机器,整体搬到甲骨文(前端静态文件和 auth-hub 本来就在这台)。

跟旧实现的三处关键差异:
- 换 token / 拉 JWKS 走 AUTH_HUB_INTERNAL_BASE(本机 :5300),不再跨公网 TLS。
  旧链路上 PyJWKClient 用 urllib + 系统 CA(群晖易 CERTIFICATE_VERIFY_FAILED)、
  两机时钟偏差会让 iat 显得来自未来,这两个坑一起消失;iss 校验和浏览器跳转
  仍用公网 issuer
- 会话改无状态 HS256 签名 cookie,服务重启不掉线(旧实现是进程内 token 表)
- 回调失败渲染错误页,不再 302 回 /login。旧实现失败即跳 /login,而 auth-hub
  只要还有会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报
  「重定向次数过多」,既看不到登录页也看不到原因

fam-core 侧删除 auth.py 与 test_auth.py,鉴权改由甲骨文 Caddy 的 forward_auth
前置拦截(打 /api/auth/verify)。

测试:fam-edge 新增 16 个用例,含「失败分支绝不 302」回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:02:59 +08:00

242 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一登录2026-09-12 从 NAS fam-core 迁来)的单测。
除了把原 fam-core/tests/test_auth.py 的用例搬过来,额外盯死三件迁移时的关键行为:
回调失败不准再 302防死循环复发、服务端调 auth-hub 走内网地址但 iss 仍按公网校验、
会话 cookie 必须是无状态的(不依赖任何进程内状态)。
"""
import time
import jwt
import pytest
from flask import Flask
from fam_edge import auth
@pytest.fixture(autouse=True)
def _clean_env_and_state(monkeypatch):
for var in ('AUTH_HUB_ISSUER', 'AUTH_HUB_INTERNAL_BASE', 'AUTH_HUB_CLIENT_ID',
'AUTH_HUB_CLIENT_SECRET', 'AUTH_HUB_REDIRECT_URI', 'FAM_SESSION_SECRET'):
monkeypatch.delenv(var, raising=False)
auth._pending.clear()
auth._warned_unconfigured = False
auth._jwks_client = None
yield
auth._pending.clear()
def _set_env(monkeypatch, internal_base=None):
monkeypatch.setenv('AUTH_HUB_ISSUER', 'https://auth.example')
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
monkeypatch.setenv('AUTH_HUB_CLIENT_SECRET', 'sekret')
monkeypatch.setenv('AUTH_HUB_REDIRECT_URI', 'https://cam.example/api/auth/callback')
monkeypatch.setenv('FAM_SESSION_SECRET', 'session-signing-secret')
if internal_base:
monkeypatch.setenv('AUTH_HUB_INTERNAL_BASE', internal_base)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth.auth_bp)
return app.test_client()
def _cookie(secret='session-signing-secret', username='ericwyuan', ttl=3600):
now = int(time.time())
return jwt.encode({'sub': '1', 'username': username, 'iat': now, 'exp': now + ttl},
secret, algorithm='HS256')
# ---------------------------------------------------------------------------
# 配置齐全性fail closed
# ---------------------------------------------------------------------------
def test_require_config_fails_closed_when_unconfigured():
assert auth._require_config() is None
def test_require_config_fails_closed_without_session_secret(monkeypatch):
_set_env(monkeypatch)
monkeypatch.delenv('FAM_SESSION_SECRET')
assert auth._require_config() is None
def test_login_rejects_when_unconfigured(client):
resp = client.get('/login')
assert resp.status_code == 503
# 配置缺失也不能跳转,否则同样会跟 auth-hub 对跳
assert 'Location' not in resp.headers
# ---------------------------------------------------------------------------
# /login
# ---------------------------------------------------------------------------
def test_login_redirects_to_authorize_with_pkce(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/login')
assert resp.status_code == 302
location = resp.headers['Location']
assert location.startswith('https://auth.example/authorize?')
assert 'code_challenge=' in location
assert 'code_challenge_method=S256' in location
assert 'client_id=fam-core' in location
assert len(auth._pending) == 1
def test_login_always_sends_browser_to_public_issuer(client, monkeypatch):
"""内网地址只给服务端自己用,浏览器必须跳公网——跳 127.0.0.1 用户当然打不开。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
resp = client.get('/login')
assert resp.headers['Location'].startswith('https://auth.example/authorize?')
def test_login_redirects_home_when_already_authed(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.get('/login')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
# ---------------------------------------------------------------------------
# 回调:失败分支一律错误页,绝不再跳 /login旧实现死循环的根因
# ---------------------------------------------------------------------------
def test_callback_unknown_state_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?state=nope&code=abc')
assert resp.status_code == 400
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
def test_callback_idp_error_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?error=access_denied&state=x')
assert resp.status_code == 403
assert 'Location' not in resp.headers
def test_callback_token_failure_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
class _Resp:
status_code = 400
text = 'invalid_grant'
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _Resp())
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 502
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
# ---------------------------------------------------------------------------
# 回调:成功路径
# ---------------------------------------------------------------------------
def _stub_successful_exchange(monkeypatch, captured):
class _Resp:
status_code = 200
text = ''
def json(self):
return {'id_token': 'the-id-token', 'access_token': 'x'}
def _post(url, **kwargs):
captured['token_url'] = url
return _Resp()
monkeypatch.setattr(auth.requests, 'post', _post)
class _FakeKey:
key = 'unused'
class _FakeJwks:
uri = 'unused'
def get_signing_key_from_jwt(self, token):
return _FakeKey()
def _jwks(uri):
captured['jwks_uri'] = uri
return _FakeJwks()
monkeypatch.setattr(auth, '_get_jwks_client', _jwks)
real_decode = auth.jwt.decode
def _decode(token, key, **kw):
# id_token 走 RS256记下校验参数并返回固定 claims
# 会话 cookie 走 HS256交给真正的实现别把验签也 mock 掉
if kw.get('algorithms') == ['RS256']:
captured['decode_kwargs'] = kw
return {'sub': '1', 'preferred_username': 'ericwyuan'}
return real_decode(token, key, **kw)
monkeypatch.setattr(auth.jwt, 'decode', _decode)
def test_callback_success_sets_cookie_and_goes_home(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
assert 'fam_session=' in resp.headers['Set-Cookie']
assert 'HttpOnly' in resp.headers['Set-Cookie']
assert 'thestate' not in auth._pending # 用过的 state 必须立刻作废
def test_callback_talks_to_internal_base_but_validates_public_issuer(client, monkeypatch):
"""换 token / 拉 JWKS 走本机,避免公网 TLS 那条链路上的 CA 和时钟坑;
但 id_token 里的 iss 是 auth-hub 配置的公网地址,校验必须按公网来。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
client.get('/api/auth/callback?state=thestate&code=abc')
assert captured['token_url'] == 'http://127.0.0.1:5300/token'
assert captured['jwks_uri'] == 'http://127.0.0.1:5300/.well-known/jwks.json'
assert captured['decode_kwargs']['issuer'] == 'https://auth.example'
assert captured['decode_kwargs']['audience'] == 'fam-core'
assert captured['decode_kwargs']['leeway'] == auth._LEEWAY
# ---------------------------------------------------------------------------
# 会话 cookie / check / verify / logout
# ---------------------------------------------------------------------------
def test_session_is_stateless(client, monkeypatch):
"""cookie 自带签名,服务端不存任何东西——换个进程、重启服务照样认。"""
_set_env(monkeypatch)
assert auth._pending == {}
client.set_cookie('fam_session', _cookie())
assert client.get('/api/auth/verify').status_code == 204
def test_verify_rejects_missing_expired_and_forged_cookies(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(ttl=-10))
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(secret='wrong-secret'))
resp = client.get('/api/auth/verify')
assert resp.status_code == 401
assert resp.get_json()['error'] == '未登录'
def test_check_reports_auth_state(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/check').get_json() == {'authed': False, 'username': ''}
client.set_cookie('fam_session', _cookie(username='ericwyuan'))
assert client.get('/api/auth/check').get_json() == {'authed': True, 'username': 'ericwyuan'}
def test_logout_clears_cookie(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.post('/api/logout')
assert resp.status_code == 200
assert 'fam_session=;' in resp.headers['Set-Cookie']