Remove CDN-backed admin auth templates
This commit is contained in:
160
app/admin.py
160
app/admin.py
@@ -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.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user