diff --git a/apps/api/app/api/v1/auth.py b/apps/api/app/api/v1/auth.py index 37842a1..72f030f 100644 --- a/apps/api/app/api/v1/auth.py +++ b/apps/api/app/api/v1/auth.py @@ -33,13 +33,12 @@ router = APIRouter(prefix="/auth", tags=["auth"]) 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: """Required for register/login/request-password-reset when the caller isn't on loopback and a secret is configured - skipped entirely otherwise (see turnstile_service.turnstile_required). The secret may be 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): return if not token or not await verify_turnstile(token, client_ip, effective): @@ -55,8 +54,13 @@ async def register( db: AsyncSession = Depends(get_db), ) -> UserResponse: client_ip = get_client_ip(request, settings) - await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip) - user = await auth_service.register(db, settings, client_ip, payload) + # Effective (DB-override-aware) settings, computed once and reused for + # 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) @@ -69,8 +73,9 @@ async def login( db: AsyncSession = Depends(get_db), ) -> TokenResponse: client_ip = get_client_ip(request, settings) - await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip) - return await auth_service.login(db, settings, client_ip, payload) + effective = await system_secret_service.get_effective_settings(db, settings) + 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) @@ -112,7 +117,8 @@ async def resend_verification( db: AsyncSession = Depends(get_db), ) -> None: 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) @@ -123,8 +129,9 @@ async def request_password_reset( db: AsyncSession = Depends(get_db), ) -> None: client_ip = get_client_ip(request, settings) - await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip) - await auth_service.request_password_reset(db, settings, client_ip, payload) + effective = await system_secret_service.get_effective_settings(db, settings) + 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) diff --git a/apps/api/app/models/enums.py b/apps/api/app/models/enums.py index 18c67ca..a2eed64 100644 --- a/apps/api/app/models/enums.py +++ b/apps/api/app/models/enums.py @@ -174,3 +174,4 @@ class SystemSecretKey(StrEnum): TURNSTILE_SITE_KEY = "turnstile_site_key" TURNSTILE_SECRET = "turnstile_secret" + RESEND_API_KEY = "resend_api_key" diff --git a/apps/api/app/services/system_secret_service.py b/apps/api/app/services/system_secret_service.py index df6d219..9968866 100644 --- a/apps/api/app/services/system_secret_service.py +++ b/apps/api/app/services/system_secret_service.py @@ -1,10 +1,11 @@ """Server-wide secrets an admin can configure from the Settings page -instead of only via .env - today just the Cloudflare Turnstile site key -and secret (app/services/turnstile_service.py). Storage is encrypted at -rest (app/core/crypto.py). Unlike per-user API keys -(user_api_key_service.py), there's exactly one value per key, shared by -the whole app - visible/editable only to admins (see -app/api/v1/system.py's require_admin gate), never per-user. +instead of only via .env - the Cloudflare Turnstile site key and secret +(app/services/turnstile_service.py) and the Resend API key +(app/services/security_email_service.py). Storage is encrypted at rest +(app/core/crypto.py). Unlike per-user API keys (user_api_key_service.py), +there's exactly one value per key, shared by the whole app - +visible/editable only to admins (see app/api/v1/system.py's require_admin +gate), never per-user. """ from __future__ import annotations @@ -35,6 +36,9 @@ META: dict[SystemSecretKey, SecretMeta] = { SystemSecretKey.TURNSTILE_SECRET: SecretMeta( label="Cloudflare Turnstile Secret Key", settings_field="turnstile_secret" ), + SystemSecretKey.RESEND_API_KEY: SecretMeta( + label="Resend API Key", settings_field="resend_api_key" + ), } diff --git a/apps/api/tests/unit/test_system_secrets.py b/apps/api/tests/unit/test_system_secrets.py index 626ad87..0cae3b8 100644 --- a/apps/api/tests/unit/test_system_secrets.py +++ b/apps/api/tests/unit/test_system_secrets.py @@ -94,10 +94,14 @@ async def test_repository_upsert_then_get_then_delete(db_session): # --- 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() 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["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 +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 ----------------------------------------------------------- diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index cd48390..038a21b 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -401,7 +401,8 @@ function ServerSecretsBox() { Server secrets

- 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.

{isLoading ? (