feat: add AUTH_DISABLED no-auth fallback mode
- config.py: AUTH_DISABLED: bool = False setting - middleware/auth.py: bypass all checks when AUTH_DISABLED=True - security.py: require_admin_auth returns synthetic context when disabled - endpoints/auth.py: /me returns synthetic admin; /sign-out → / when disabled - main.py: startup WARNING when disabled; pass auth_disabled to login.html - templates/login.html: info banner with Go to dashboard link when disabled - templates/setup.html: document AUTH_DISABLED option with security warning - tests/test_auth.py: 4 new AUTH_DISABLED tests (445 total, all pass) Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/18f41bf2-0b68-4b7d-afb5-d2894c212a8f Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -166,9 +166,14 @@ async def sign_out(request: Request) -> RedirectResponse:
|
||||
"""
|
||||
Sign the user out.
|
||||
|
||||
Clears the app session cookie and redirects to Logto's end-session
|
||||
When ``AUTH_DISABLED=true`` there is nothing to sign out of; redirects to ``/``.
|
||||
|
||||
Otherwise clears the app session cookie and redirects to Logto's end-session
|
||||
endpoint (if available) so that the Logto session is terminated too.
|
||||
"""
|
||||
if settings.AUTH_DISABLED:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
post_logout_url = str(request.base_url).rstrip("/")
|
||||
|
||||
# Best-effort: obtain Logto's end-session URL from OIDC metadata.
|
||||
@@ -200,9 +205,25 @@ async def get_current_user(
|
||||
"""
|
||||
Return the profile of the currently authenticated user.
|
||||
|
||||
Reads the ``dmarq_session`` cookie (issued at callback time) and looks up
|
||||
the corresponding local ``User`` record.
|
||||
When ``AUTH_DISABLED=true`` a synthetic anonymous-admin profile is returned
|
||||
so that UI components (e.g. the navbar user menu) work without a real session.
|
||||
|
||||
Otherwise reads the ``dmarq_session`` cookie (issued at callback time) and
|
||||
looks up the corresponding local ``User`` record.
|
||||
"""
|
||||
# Auth-disabled: return a synthetic profile so the UI renders correctly.
|
||||
if settings.AUTH_DISABLED:
|
||||
return {
|
||||
"id": 0,
|
||||
"email": "admin@localhost",
|
||||
"full_name": "Local Admin",
|
||||
"username": "admin",
|
||||
"picture": None,
|
||||
"is_superuser": True,
|
||||
"logto_id": None,
|
||||
"auth_disabled": True,
|
||||
}
|
||||
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -54,6 +54,15 @@ class Settings(BaseSettings):
|
||||
# Use: openssl rand -hex 32
|
||||
ADMIN_API_KEY: Optional[str] = None
|
||||
|
||||
# ── Authentication mode ───────────────────────────────────────────────────
|
||||
# Set AUTH_DISABLED=true to run without any authentication.
|
||||
# Every request is treated as an anonymous admin.
|
||||
#
|
||||
# ⚠️ Only use this for local development or deployments that are protected
|
||||
# by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth).
|
||||
# Never expose an AUTH_DISABLED instance directly to the internet.
|
||||
AUTH_DISABLED: bool = False
|
||||
|
||||
# ── Logto OIDC ────────────────────────────────────────────────────────────
|
||||
# Set these to enable Logto-based authentication.
|
||||
# LOGTO_ENDPOINT: the base URL of your Logto instance,
|
||||
|
||||
@@ -170,13 +170,18 @@ async def require_admin_auth(
|
||||
Dependency to require authentication for admin/API endpoints.
|
||||
|
||||
Accepts (in priority order):
|
||||
1. ``dmarq_session`` cookie – set after a successful Logto login.
|
||||
2. ``X-API-Key`` header – static admin key for programmatic access.
|
||||
3. ``Authorization: Bearer <token>`` header – app-issued JWT.
|
||||
1. ``AUTH_DISABLED=true`` env var – passes through with a synthetic context.
|
||||
2. ``dmarq_session`` cookie – set after a successful Logto login.
|
||||
3. ``X-API-Key`` header – static admin key for programmatic access.
|
||||
4. ``Authorization: Bearer <token>`` header – app-issued JWT.
|
||||
|
||||
Returns an authentication context dict describing how the request was
|
||||
authenticated. Raises ``HTTP 401`` when no valid credential is present.
|
||||
"""
|
||||
# 0. Auth globally disabled
|
||||
if settings.AUTH_DISABLED:
|
||||
return {"auth_type": "disabled"}
|
||||
|
||||
# 1. Session cookie (Logto-backed app session)
|
||||
from app.core.logto import SESSION_COOKIE, decode_session_token # local import
|
||||
|
||||
|
||||
@@ -300,6 +300,18 @@ def create_app() -> FastAPI:
|
||||
# Ensure all tables exist (no-op if already present)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Warn loudly when authentication is completely disabled
|
||||
if settings.AUTH_DISABLED:
|
||||
logger.warning(
|
||||
"%s\n"
|
||||
"⚠️ AUTH_DISABLED=true — authentication is turned OFF.\n"
|
||||
"All requests have unrestricted admin access.\n"
|
||||
"Do NOT expose this instance directly to the internet.\n"
|
||||
"%s",
|
||||
"=" * 80,
|
||||
"=" * 80,
|
||||
)
|
||||
|
||||
# Load or generate the admin API key
|
||||
if settings.ADMIN_API_KEY:
|
||||
api_key = settings.ADMIN_API_KEY
|
||||
@@ -373,6 +385,7 @@ async def login(request: Request, next: str = "/"):
|
||||
{
|
||||
"app_name": settings.PROJECT_NAME,
|
||||
"logto_configured": settings.logto_configured,
|
||||
"auth_disabled": settings.AUTH_DISABLED,
|
||||
"next": next,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -56,6 +56,13 @@ class AuthRedirectMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
|
||||
path = request.url.path
|
||||
|
||||
# ── 0. Auth disabled globally ─────────────────────────────────────────
|
||||
from app.core.config import get_settings # local import avoids circular dep
|
||||
|
||||
cfg = get_settings()
|
||||
if cfg.AUTH_DISABLED:
|
||||
return await call_next(request)
|
||||
|
||||
# ── 1. Public paths & prefixes ────────────────────────────────────────
|
||||
if path in _PUBLIC_PATHS:
|
||||
return await call_next(request)
|
||||
@@ -68,9 +75,7 @@ class AuthRedirectMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
# ── 3. Logto not configured ───────────────────────────────────────────
|
||||
from app.core.config import get_settings # local import avoids circular dep
|
||||
|
||||
if not get_settings().logto_configured:
|
||||
if not cfg.logto_configured:
|
||||
return RedirectResponse(url="/setup", status_code=302)
|
||||
|
||||
# ── 4. Redirect to login ──────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +26,22 @@
|
||||
<div class="card-body gap-6">
|
||||
<h1 class="card-title text-xl justify-center">Sign in to your account</h1>
|
||||
|
||||
{% if not logto_configured %}
|
||||
{% if auth_disabled %}
|
||||
<!-- Auth disabled mode -->
|
||||
<div role="alert" class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Authentication is disabled</p>
|
||||
<p class="text-sm">
|
||||
<code class="font-mono bg-base-200 px-1 rounded">AUTH_DISABLED=true</code>
|
||||
is set. All requests have full access — no sign-in required.
|
||||
<a href="/" class="link link-info font-medium">Go to dashboard →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not logto_configured %}
|
||||
<!-- Logto not yet configured -->
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -158,6 +158,35 @@ LOGTO_APP_SECRET=<your-app-secret>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alternative: disable auth entirely -->
|
||||
<div class="card bg-base-100 shadow border border-warning/40">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg text-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
Alternative: disable authentication entirely
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
If you're running {{ app_name }} locally or behind a trusted reverse proxy that
|
||||
already handles authentication, you can skip Logto and grant everyone full access
|
||||
by setting:
|
||||
</p>
|
||||
<div class="mockup-code text-xs mt-2">
|
||||
<pre><code>AUTH_DISABLED=true</code></pre>
|
||||
</div>
|
||||
<div role="alert" class="alert alert-warning mt-3 text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
<p>
|
||||
<strong>Never</strong> set <code class="font-mono">AUTH_DISABLED=true</code> on a
|
||||
publicly reachable instance. Anyone with network access will have full admin access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if logto_configured %}
|
||||
<div class="text-center mt-4">
|
||||
<a href="/login" class="btn btn-primary btn-lg">Go to Sign-in</a>
|
||||
|
||||
@@ -237,3 +237,59 @@ class TestSignOutEndpoint:
|
||||
set_cookie = res.headers.get("set-cookie", "")
|
||||
assert SESSION_COOKIE in set_cookie
|
||||
assert "Max-Age=0" in set_cookie or "max-age=0" in set_cookie
|
||||
|
||||
|
||||
# ── AUTH_DISABLED mode ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthDisabled:
|
||||
"""Verify the AUTH_DISABLED=true no-auth fallback mode."""
|
||||
|
||||
def test_me_returns_synthetic_admin_when_auth_disabled(self, client: TestClient):
|
||||
"""With AUTH_DISABLED, /me must return the synthetic admin profile."""
|
||||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
res = client.get("/api/v1/auth/me")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["is_superuser"] is True
|
||||
assert data["auth_disabled"] is True
|
||||
assert data["email"] == "admin@localhost"
|
||||
|
||||
def test_sign_out_redirects_to_root_when_auth_disabled(self, client: TestClient):
|
||||
"""With AUTH_DISABLED, sign-out should redirect to / (no Logto session to clear)."""
|
||||
with patch("app.api.api_v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
res = client.get("/api/v1/auth/sign-out", follow_redirects=False)
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"] == "/"
|
||||
|
||||
def test_require_admin_auth_passes_when_disabled(self):
|
||||
"""require_admin_auth must return a synthetic context when AUTH_DISABLED=True."""
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.core.security import require_admin_auth
|
||||
|
||||
with patch("app.core.security.settings") as mock_settings:
|
||||
mock_settings.AUTH_DISABLED = True
|
||||
mock_req = MagicMock()
|
||||
mock_req.cookies = {}
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
require_admin_auth(request=mock_req, api_key=None, bearer=None)
|
||||
)
|
||||
assert result["auth_type"] == "disabled"
|
||||
|
||||
def test_middleware_passes_all_requests_when_auth_disabled(self, client: TestClient):
|
||||
"""The auth middleware must let every request through when AUTH_DISABLED=True."""
|
||||
# The middleware does `from app.core.config import get_settings` inside dispatch,
|
||||
# so we patch the canonical location used at call time.
|
||||
with patch("app.core.config.get_settings") as mock_get_settings:
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.AUTH_DISABLED = True
|
||||
mock_get_settings.return_value = mock_cfg
|
||||
# Even without a session cookie, the middleware lets the request through.
|
||||
# The endpoint itself then handles auth (API key or 401), but it must
|
||||
# never be a 302 redirect from the middleware.
|
||||
res = client.get("/settings", follow_redirects=False)
|
||||
assert res.status_code != 302
|
||||
|
||||
Reference in New Issue
Block a user