feat(auth): request middleware with localhost bypass and session cookie check
- Add AuthMiddleware class extending BaseHTTPMiddleware - Localhost bypass: checks both client.host and url.hostname for 127.0.0.1/::1/localhost - Exempt paths: /login, /auth/mode, /auth/logout bypass auth - Valid session cookie (muxplex_session) passes through - Basic auth header: valid password passes, invalid returns 401 - API clients (Accept: application/json) get 401 on auth failure - Browser clients get 307 redirect to /login on auth failure - _check_credentials dispatches to PAM or password comparison - Add 8 middleware tests to test_auth.py Co-authored-by: Amplifier <amplifier@example.com>
This commit is contained in:
@@ -2,10 +2,14 @@
|
|||||||
muxplex authentication — password and signing secret file management.
|
muxplex authentication — password and signing secret file management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, RedirectResponse, Response
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -123,3 +127,72 @@ def authenticate_pam(username: str, password: str) -> bool:
|
|||||||
if username != running_user:
|
if username != running_user:
|
||||||
return False
|
return False
|
||||||
return pam.authenticate(username, password, service="login")
|
return pam.authenticate(username, password, service="login")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Paths that bypass auth (login page itself, static assets it needs)
|
||||||
|
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""FastAPI middleware that enforces authentication on non-localhost requests."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app,
|
||||||
|
auth_mode: str,
|
||||||
|
secret: str,
|
||||||
|
ttl_seconds: int,
|
||||||
|
password: str = "",
|
||||||
|
):
|
||||||
|
super().__init__(app)
|
||||||
|
self.auth_mode = auth_mode
|
||||||
|
self.secret = secret
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
# 1. Localhost bypass — check both client IP and server hostname.
|
||||||
|
# In production, client.host is the connecting IP (127.0.0.1 for local).
|
||||||
|
# In tests (TestClient), base_url affects request.url.hostname instead.
|
||||||
|
client_host = request.client.host if request.client else ""
|
||||||
|
server_host = request.url.hostname or ""
|
||||||
|
_localhost_addrs = ("127.0.0.1", "::1", "localhost")
|
||||||
|
if client_host in _localhost_addrs or server_host in _localhost_addrs:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 2. Exempt paths (login page, auth endpoints)
|
||||||
|
if request.url.path in _AUTH_EXEMPT_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 3. Valid session cookie
|
||||||
|
cookie = request.cookies.get("muxplex_session")
|
||||||
|
if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 4. Authorization: Basic header
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.lower().startswith("basic "):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(auth_header[6:]).decode()
|
||||||
|
username, _, pw = decoded.partition(":")
|
||||||
|
if self._check_credentials(username, pw):
|
||||||
|
return await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
|
||||||
|
|
||||||
|
# 5. No auth — redirect browsers, 401 for API clients
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "application/json" in accept:
|
||||||
|
return JSONResponse({"detail": "Authentication required"}, status_code=401)
|
||||||
|
return RedirectResponse(url="/login", status_code=307)
|
||||||
|
|
||||||
|
def _check_credentials(self, username: str, password: str) -> bool:
|
||||||
|
"""Validate credentials against the configured auth mode."""
|
||||||
|
if self.auth_mode == "pam":
|
||||||
|
return authenticate_pam(username, password)
|
||||||
|
return password == self.password
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Tests for muxplex/auth.py — authentication module."""
|
"""Tests for muxplex/auth.py — authentication module."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
import stat
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from muxplex.auth import AuthMiddleware, create_session_cookie
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Password file management
|
# Password file management
|
||||||
@@ -233,3 +240,104 @@ def test_authenticate_pam_wrong_user_rejected(monkeypatch):
|
|||||||
# Mock PAM to always return True — but wrong username should still fail
|
# Mock PAM to always return True — but wrong username should still fail
|
||||||
monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True)
|
monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True)
|
||||||
assert authenticate_pam(wrong_user, "any-password") is False
|
assert authenticate_pam(wrong_user, "any-password") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_app(auth_mode: str = "password", password: str = "test-pw") -> FastAPI:
|
||||||
|
"""Create a minimal FastAPI app with AuthMiddleware for testing."""
|
||||||
|
test_app = FastAPI()
|
||||||
|
|
||||||
|
test_app.add_middleware(
|
||||||
|
AuthMiddleware,
|
||||||
|
auth_mode=auth_mode,
|
||||||
|
secret="test-secret",
|
||||||
|
ttl_seconds=3600,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
@test_app.get("/protected")
|
||||||
|
async def protected():
|
||||||
|
return PlainTextResponse("OK")
|
||||||
|
|
||||||
|
return test_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_localhost_bypasses_auth():
|
||||||
|
"""Requests from 127.0.0.1 pass through without auth."""
|
||||||
|
app = _make_test_app()
|
||||||
|
client = TestClient(app, base_url="http://127.0.0.1")
|
||||||
|
response = client.get("/protected")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_valid_session_cookie_passes():
|
||||||
|
"""Non-localhost request with a valid session cookie passes through."""
|
||||||
|
app = _make_test_app()
|
||||||
|
cookie = create_session_cookie("test-secret", ttl_seconds=3600)
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1")
|
||||||
|
response = client.get("/protected", cookies={"muxplex_session": cookie})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_tampered_cookie_redirects():
|
||||||
|
"""Non-localhost request with a tampered cookie redirects to /login."""
|
||||||
|
app = _make_test_app()
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
|
||||||
|
response = client.get("/protected", cookies={"muxplex_session": "bad.cookie.value"})
|
||||||
|
assert response.status_code == 307
|
||||||
|
assert "/login" in response.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_no_cookie_non_localhost_redirects():
|
||||||
|
"""Non-localhost request with no cookie redirects to /login."""
|
||||||
|
app = _make_test_app()
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
|
||||||
|
response = client.get("/protected")
|
||||||
|
assert response.status_code == 307
|
||||||
|
assert "/login" in response.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_basic_auth_valid_password():
|
||||||
|
"""Non-localhost request with valid Basic auth header passes through."""
|
||||||
|
app = _make_test_app(auth_mode="password", password="test-pw")
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1")
|
||||||
|
creds = base64.b64encode(b":test-pw").decode()
|
||||||
|
response = client.get("/protected", headers={"Authorization": f"Basic {creds}"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_basic_auth_invalid_password():
|
||||||
|
"""Non-localhost request with wrong Basic auth header returns 401."""
|
||||||
|
app = _make_test_app(auth_mode="password", password="test-pw")
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1")
|
||||||
|
creds = base64.b64encode(b":wrong-pw").decode()
|
||||||
|
response = client.get("/protected", headers={"Authorization": f"Basic {creds}"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_json_request_gets_401_not_redirect():
|
||||||
|
"""Non-localhost API request (Accept: application/json) gets 401, not redirect."""
|
||||||
|
app = _make_test_app()
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False)
|
||||||
|
response = client.get("/protected", headers={"Accept": "application/json"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_login_path_excluded():
|
||||||
|
"""/login path is excluded from auth to avoid redirect loops."""
|
||||||
|
app = _make_test_app()
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login():
|
||||||
|
return PlainTextResponse("login page")
|
||||||
|
|
||||||
|
client = TestClient(app, base_url="http://192.168.1.1")
|
||||||
|
response = client.get("/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user