Add Resend API key to admin-configurable Server secrets
Was only ever settable via .env - now follows the same pattern as Cloudflare Turnstile: SystemSecretKey.RESEND_API_KEY + a META entry covers storage/encryption/frontend rendering automatically (the Settings UI's Server secrets box is fully data-driven off this list). Wired the register/login/resend-verification/request-password-reset route handlers to use get_effective_settings so an admin-set key actually reaches the emails those flows send, not just .env's value. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -33,13 +33,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|||||||
|
|
||||||
|
|
||||||
async def _enforce_turnstile(
|
async def _enforce_turnstile(
|
||||||
request: Request, settings: Settings, db: AsyncSession, token: str | None, client_ip: str
|
request: Request, settings: Settings, effective: Settings, token: str | None, client_ip: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Required for register/login/request-password-reset when the caller
|
"""Required for register/login/request-password-reset when the caller
|
||||||
isn't on loopback and a secret is configured - skipped entirely
|
isn't on loopback and a secret is configured - skipped entirely
|
||||||
otherwise (see turnstile_service.turnstile_required). The secret may be
|
otherwise (see turnstile_service.turnstile_required). The secret may be
|
||||||
admin-configured (system_secret_service) rather than only .env-set."""
|
admin-configured (system_secret_service) rather than only .env-set."""
|
||||||
effective = await system_secret_service.get_effective_settings(db, settings)
|
|
||||||
if not turnstile_required(is_localhost(request, settings), effective):
|
if not turnstile_required(is_localhost(request, settings), effective):
|
||||||
return
|
return
|
||||||
if not token or not await verify_turnstile(token, client_ip, effective):
|
if not token or not await verify_turnstile(token, client_ip, effective):
|
||||||
@@ -55,8 +54,13 @@ async def register(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
client_ip = get_client_ip(request, settings)
|
client_ip = get_client_ip(request, settings)
|
||||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
# Effective (DB-override-aware) settings, computed once and reused for
|
||||||
user = await auth_service.register(db, settings, client_ip, payload)
|
# both Turnstile enforcement and the verification email - the Resend
|
||||||
|
# API key, like the Turnstile secret, may be admin-configured via
|
||||||
|
# system_secret_service rather than only .env-set.
|
||||||
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
|
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
|
||||||
|
user = await auth_service.register(db, effective, client_ip, payload)
|
||||||
return UserResponse.model_validate(user)
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +73,9 @@ async def login(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
client_ip = get_client_ip(request, settings)
|
client_ip = get_client_ip(request, settings)
|
||||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
return await auth_service.login(db, settings, client_ip, payload)
|
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
|
||||||
|
return await auth_service.login(db, effective, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
@@ -112,7 +117,8 @@ async def resend_verification(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
client_ip = get_client_ip(request, settings)
|
client_ip = get_client_ip(request, settings)
|
||||||
await auth_service.resend_verification(db, settings, client_ip, payload)
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
|
await auth_service.resend_verification(db, effective, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -123,8 +129,9 @@ async def request_password_reset(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> None:
|
) -> None:
|
||||||
client_ip = get_client_ip(request, settings)
|
client_ip = get_client_ip(request, settings)
|
||||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
await auth_service.request_password_reset(db, settings, client_ip, payload)
|
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
|
||||||
|
await auth_service.request_password_reset(db, effective, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -174,3 +174,4 @@ class SystemSecretKey(StrEnum):
|
|||||||
|
|
||||||
TURNSTILE_SITE_KEY = "turnstile_site_key"
|
TURNSTILE_SITE_KEY = "turnstile_site_key"
|
||||||
TURNSTILE_SECRET = "turnstile_secret"
|
TURNSTILE_SECRET = "turnstile_secret"
|
||||||
|
RESEND_API_KEY = "resend_api_key"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Server-wide secrets an admin can configure from the Settings page
|
"""Server-wide secrets an admin can configure from the Settings page
|
||||||
instead of only via .env - today just the Cloudflare Turnstile site key
|
instead of only via .env - the Cloudflare Turnstile site key and secret
|
||||||
and secret (app/services/turnstile_service.py). Storage is encrypted at
|
(app/services/turnstile_service.py) and the Resend API key
|
||||||
rest (app/core/crypto.py). Unlike per-user API keys
|
(app/services/security_email_service.py). Storage is encrypted at rest
|
||||||
(user_api_key_service.py), there's exactly one value per key, shared by
|
(app/core/crypto.py). Unlike per-user API keys (user_api_key_service.py),
|
||||||
the whole app - visible/editable only to admins (see
|
there's exactly one value per key, shared by the whole app -
|
||||||
app/api/v1/system.py's require_admin gate), never per-user.
|
visible/editable only to admins (see app/api/v1/system.py's require_admin
|
||||||
|
gate), never per-user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,6 +36,9 @@ META: dict[SystemSecretKey, SecretMeta] = {
|
|||||||
SystemSecretKey.TURNSTILE_SECRET: SecretMeta(
|
SystemSecretKey.TURNSTILE_SECRET: SecretMeta(
|
||||||
label="Cloudflare Turnstile Secret Key", settings_field="turnstile_secret"
|
label="Cloudflare Turnstile Secret Key", settings_field="turnstile_secret"
|
||||||
),
|
),
|
||||||
|
SystemSecretKey.RESEND_API_KEY: SecretMeta(
|
||||||
|
label="Resend API Key", settings_field="resend_api_key"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,10 +94,14 @@ async def test_repository_upsert_then_get_then_delete(db_session):
|
|||||||
# --- Service -------------------------------------------------------------
|
# --- Service -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def test_list_status_shows_both_keys_unconfigured_by_default(db_session):
|
async def test_list_status_shows_all_keys_unconfigured_by_default(db_session):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
statuses = await system_secret_service.list_status(db_session, settings)
|
statuses = await system_secret_service.list_status(db_session, settings)
|
||||||
assert {s["key"] for s in statuses} == {"turnstile_site_key", "turnstile_secret"}
|
assert {s["key"] for s in statuses} == {
|
||||||
|
"turnstile_site_key",
|
||||||
|
"turnstile_secret",
|
||||||
|
"resend_api_key",
|
||||||
|
}
|
||||||
assert all(s["configured"] is False for s in statuses)
|
assert all(s["configured"] is False for s in statuses)
|
||||||
assert all(s["value"] is None for s in statuses)
|
assert all(s["value"] is None for s in statuses)
|
||||||
|
|
||||||
@@ -165,6 +169,20 @@ async def test_get_effective_settings_overrides_only_the_keys_that_were_set(db_s
|
|||||||
assert effective.turnstile_secret == "global-secret" # untouched, no override set
|
assert effective.turnstile_secret == "global-secret" # untouched, no override set
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_configured_resend_key_overrides_the_env_value(db_session):
|
||||||
|
settings = get_settings().model_copy(update={"resend_api_key": "global-resend-key"})
|
||||||
|
await system_secret_service.set_secret(
|
||||||
|
db_session,
|
||||||
|
SystemSecretKey.RESEND_API_KEY,
|
||||||
|
"admin-resend-key",
|
||||||
|
settings,
|
||||||
|
admin_user_id=uuid.uuid4(),
|
||||||
|
client_ip="10.0.0.1",
|
||||||
|
)
|
||||||
|
effective = await system_secret_service.get_effective_settings(db_session, settings)
|
||||||
|
assert effective.resend_api_key == "admin-resend-key"
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints -----------------------------------------------------------
|
# --- Endpoints -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -401,7 +401,8 @@ function ServerSecretsBox() {
|
|||||||
<KeyRound className="h-4 w-4" aria-hidden /> Server secrets
|
<KeyRound className="h-4 w-4" aria-hidden /> Server secrets
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
Server-wide configuration shared by every visitor, used for Cloudflare Turnstile CAPTCHA.
|
Server-wide configuration shared by every visitor: Cloudflare Turnstile CAPTCHA and the
|
||||||
|
Resend API key used for transactional security email.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user