Remove CDN-backed admin auth templates

This commit is contained in:
dwindown
2026-04-01 20:53:47 +07:00
parent 3023d8aa57
commit 83adb325e8

View File

@@ -14,14 +14,13 @@ import aioredis
from fastapi import Depends, Form, HTTPException, Request from fastapi import Depends, Form, HTTPException, Request
from fastapi_admin import constants from fastapi_admin import constants
from fastapi_admin.app import app as admin_app from fastapi_admin.app import app as admin_app
from fastapi_admin.depends import get_current_admin, get_resources from fastapi_admin.depends import get_current_admin
from fastapi_admin.providers import Provider from fastapi_admin.providers import Provider
from fastapi_admin.resources import ( from fastapi_admin.resources import (
Field, Field,
Link, Link,
Model, Model,
) )
from fastapi_admin.template import templates
from fastapi_admin.widgets import displays, inputs from fastapi_admin.widgets import displays, inputs
from sqlalchemy import select from sqlalchemy import select
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
@@ -70,7 +69,6 @@ class EnvCredentialProvider(Provider):
login_title: str = "Admin Login", login_title: str = "Admin Login",
login_logo_url: str | None = None, login_logo_url: str | None = None,
expire_seconds: int = 3600, expire_seconds: int = 3600,
template: str = "providers/login/login.html",
) -> None: ) -> None:
self._username = username self._username = username
self._password = password self._password = password
@@ -79,7 +77,6 @@ class EnvCredentialProvider(Provider):
self.login_title = login_title self.login_title = login_title
self.login_logo_url = login_logo_url self.login_logo_url = login_logo_url
self.expire_seconds = expire_seconds self.expire_seconds = expire_seconds
self.template = template
async def register(self, app: "FastAPIAdmin") -> None: async def register(self, app: "FastAPIAdmin") -> None:
await super().register(app) await super().register(app)
@@ -93,30 +90,45 @@ class EnvCredentialProvider(Provider):
app.post("/password")(self.password) app.post("/password")(self.password)
app.add_middleware(BaseHTTPMiddleware, dispatch=self.authenticate) app.add_middleware(BaseHTTPMiddleware, dispatch=self.authenticate)
def _template_response( def _render_auth_page(
self, self,
request: Request, request: Request,
name: str, title: str,
context: Dict[str, Any], subtitle: str,
body: str,
status_code: int = 200, status_code: int = 200,
): ) -> HTMLResponse:
"""Build a template response compatible with old/new Starlette signatures.""" remember_me_checked = "checked" if request.cookies.get("remember_me") == "on" else ""
payload = {"request": request, **context} html = f"""<!DOCTYPE html>
try: <html lang="en">
# Starlette >= 1.0 <head>
return templates.TemplateResponse( <meta charset="UTF-8">
request=request, <meta name="viewport" content="width=device-width, initial-scale=1.0">
name=name, <title>{title}</title>
context=payload, <style>
status_code=status_code, body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #f8fafc, #e2e8f0); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #0f172a; }}
) .panel {{ width: min(420px, calc(100vw - 32px)); background: rgba(255,255,255,0.96); border-radius: 18px; box-shadow: 0 18px 60px rgba(15, 23, 42, 0.14); padding: 28px; }}
except TypeError: h1 {{ margin: 0 0 8px; font-size: 28px; }}
# Starlette < 1.0 p {{ margin: 0 0 20px; color: #475569; }}
return templates.TemplateResponse( label {{ display: block; font-size: 14px; font-weight: 600; margin: 14px 0 8px; }}
name, input {{ width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 10px; padding: 12px 14px; font-size: 15px; }}
context=payload, .row {{ display: flex; align-items: center; gap: 10px; margin-top: 16px; color: #334155; font-size: 14px; }}
status_code=status_code, .row input {{ width: auto; }}
) button {{ width: 100%; margin-top: 18px; border: 0; border-radius: 10px; padding: 12px 14px; background: #0f172a; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; }}
.error {{ margin: 0 0 16px; padding: 12px 14px; border-radius: 10px; background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }}
.muted {{ color: #64748b; font-size: 13px; margin-top: 14px; }}
a {{ color: #0f172a; }}
</style>
</head>
<body>
<main class="panel">
<h1>{title}</h1>
<p>{subtitle}</p>
{body.replace("__REMEMBER_ME_CHECKED__", remember_me_checked)}
</main>
</body>
</html>"""
return HTMLResponse(html, status_code=status_code)
@staticmethod @staticmethod
def _admin_home(request: Request) -> str: def _admin_home(request: Request) -> str:
@@ -133,13 +145,22 @@ class EnvCredentialProvider(Provider):
return RedirectResponse(url=self._login_url(request), status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url=self._login_url(request), status_code=HTTP_303_SEE_OTHER)
async def login_view(self, request: Request): async def login_view(self, request: Request):
return self._template_response( body = f"""
<form method="post" action="{request.app.admin_path}{self.login_path}" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
<p class="muted">Direct environment-backed admin access.</p>
"""
return self._render_auth_page(
request=request, request=request,
name=self.template, title=self.login_title,
context={ subtitle="Use the configured admin credentials to access the dashboard.",
"login_logo_url": self.login_logo_url, body=body,
"login_title": self.login_title,
},
) )
async def login( async def login(
@@ -153,15 +174,23 @@ class EnvCredentialProvider(Provider):
secrets.compare_digest(username, self._username) secrets.compare_digest(username, self._username)
and secrets.compare_digest(password, self._password) and secrets.compare_digest(password, self._password)
): ):
return self._template_response( body = f"""
<div class="error">Invalid username or password.</div>
<form method="post" action="{request.app.admin_path}{self.login_path}" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" value="{username}">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
"""
return self._render_auth_page(
request=request, request=request,
name=self.template, title=self.login_title,
subtitle="Use the configured admin credentials to access the dashboard.",
body=body,
status_code=HTTP_401_UNAUTHORIZED, status_code=HTTP_401_UNAUTHORIZED,
context={
"error": "Invalid username or password",
"login_logo_url": self.login_logo_url,
"login_title": self.login_title,
},
) )
response = RedirectResponse(url=self._admin_home(request), status_code=HTTP_303_SEE_OTHER) response = RedirectResponse(url=self._admin_home(request), status_code=HTTP_303_SEE_OTHER)
@@ -212,11 +241,23 @@ class EnvCredentialProvider(Provider):
response.delete_cookie(self.access_token, path=request.app.admin_path) response.delete_cookie(self.access_token, path=request.app.admin_path)
return response return response
async def password_view(self, request: Request, resources=Depends(get_resources)): async def password_view(
return self._template_response( self,
request: Request,
admin: AdminPrincipal = Depends(get_current_admin),
):
body = f"""
<p class="muted">Signed in as <strong>{admin.username}</strong>.</p>
<p>Password changes are disabled in the UI for this deployment.</p>
<p>Update <code>ADMIN_PASSWORD</code> in the server environment, then restart the app.</p>
<p>Session expiry is currently set to <strong>{self.expire_seconds}</strong> seconds.</p>
<p><a href="{request.app.admin_path}/dashboard">Back to dashboard</a></p>
"""
return self._render_auth_page(
request=request, request=request,
name="providers/login/password.html", title="Password Management",
context={"resources": resources}, subtitle="Runtime password rotation is intentionally disabled.",
body=body,
) )
async def password( async def password(
@@ -226,32 +267,19 @@ class EnvCredentialProvider(Provider):
new_password: str = Form(...), new_password: str = Form(...),
re_new_password: str = Form(...), re_new_password: str = Form(...),
admin: AdminPrincipal = Depends(get_current_admin), admin: AdminPrincipal = Depends(get_current_admin),
resources=Depends(get_resources),
): ):
_ = admin _ = (old_password, new_password, re_new_password, admin)
if not secrets.compare_digest(old_password, self._password): body = f"""
return self._template_response( <div class="error">Password rotation via UI is disabled.</div>
request=request, <p>Update <code>ADMIN_PASSWORD</code> in the server environment, then restart the app.</p>
name="providers/login/password.html", <p><a href="{request.app.admin_path}/dashboard">Back to dashboard</a></p>
context={ """
"resources": resources, return self._render_auth_page(
"error": "Old password is incorrect", request=request,
}, title="Password Management",
) subtitle="Runtime password rotation is intentionally disabled.",
if new_password != re_new_password: body=body,
return self._template_response(
request=request,
name="providers/login/password.html",
context={
"resources": resources,
"error": "New passwords do not match",
},
)
# Password is env-configured and immutable at runtime.
raise HTTPException(
status_code=400, status_code=400,
detail="Password rotation via UI is disabled. Update ADMIN_PASSWORD in environment.",
) )