Reject invalid SOCIAL_TOKEN_ENCRYPTION_KEY instead of 500ing.
Placeholder Fernet values like replace-with-fernet-key now raise a clear SocialCryptoError in the portal, and prod env examples no longer ship a fake key.
This commit is contained in:
+5
-2
@@ -77,7 +77,10 @@ PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replac
|
|||||||
# LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/
|
# LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/
|
||||||
META_APP_ID=
|
META_APP_ID=
|
||||||
META_APP_SECRET=
|
META_APP_SECRET=
|
||||||
SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key
|
# Must be a real Fernet key (44 chars). Generate:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# Do NOT use a placeholder — invalid keys break LinkedIn/Meta token save.
|
||||||
|
SOCIAL_TOKEN_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Ollama for social drafting (reachable from app hosts)
|
# Ollama for social drafting (reachable from app hosts)
|
||||||
OLLAMA_BASE_URL=http://10.0.0.128:11434
|
OLLAMA_BASE_URL=http://10.0.0.128:11434
|
||||||
@@ -116,7 +119,7 @@ GUNICORN_BIND=0.0.0.0:8000
|
|||||||
# EMAIL_HOST_PASSWORD=replace-me
|
# EMAIL_HOST_PASSWORD=replace-me
|
||||||
# SMTP2GO_SMS_API_KEY=replace-me
|
# SMTP2GO_SMS_API_KEY=replace-me
|
||||||
# LOB_API_KEY=replace-me
|
# LOB_API_KEY=replace-me
|
||||||
# SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key
|
# SOCIAL_TOKEN_ENCRYPTION_KEY= # real Fernet.generate_key() output, not a placeholder
|
||||||
# OLLAMA_BASE_URL=http://10.0.0.128:11434
|
# OLLAMA_BASE_URL=http://10.0.0.128:11434
|
||||||
# NOMINATIM_BASE_URL=http://10.0.0.128:8089
|
# NOMINATIM_BASE_URL=http://10.0.0.128:8089
|
||||||
# NOMINATIM_COUNTRY_CODES=us
|
# NOMINATIM_COUNTRY_CODES=us
|
||||||
|
|||||||
+48
-8
@@ -1,17 +1,55 @@
|
|||||||
"""Token encryption helpers for SocialAccount."""
|
"""Token encryption helpers for SocialAccount / SocialAppCredentials."""
|
||||||
|
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Copied from .env.prod.example / deploy templates — not valid Fernet material.
|
||||||
|
_PLACEHOLDER_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"replace-with-fernet-key",
|
||||||
|
"changeme",
|
||||||
|
"change-me",
|
||||||
|
"todo",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialCryptoError(ValueError):
|
||||||
|
"""Raised when SOCIAL_TOKEN_ENCRYPTION_KEY is missing or not a Fernet key."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_key(raw: str | bytes | None) -> bytes | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
text = raw.decode("utf-8", errors="ignore").strip()
|
||||||
|
else:
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text or text.lower() in _PLACEHOLDER_KEYS:
|
||||||
|
return None
|
||||||
|
return text.encode()
|
||||||
|
|
||||||
|
|
||||||
def _fernet() -> Fernet:
|
def _fernet() -> Fernet:
|
||||||
key = settings.SOCIAL_TOKEN_ENCRYPTION_KEY
|
key = _normalize_key(settings.SOCIAL_TOKEN_ENCRYPTION_KEY)
|
||||||
if not key:
|
if key is None:
|
||||||
# Dev-only fallback — generate is not stable across restarts; set the env var.
|
if settings.DEBUG:
|
||||||
key = Fernet.generate_key().decode()
|
# Dev-only unstable fallback — set SOCIAL_TOKEN_ENCRYPTION_KEY for real use.
|
||||||
if isinstance(key, str):
|
return Fernet(Fernet.generate_key())
|
||||||
key = key.encode()
|
raise SocialCryptoError(
|
||||||
return Fernet(key)
|
"SOCIAL_TOKEN_ENCRYPTION_KEY is not set. Generate one with: "
|
||||||
|
'python -c "from cryptography.fernet import Fernet; '
|
||||||
|
'print(Fernet.generate_key().decode())" '
|
||||||
|
"and add it to the beta/prod app env, then redeploy/restart."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return Fernet(key)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise SocialCryptoError(
|
||||||
|
"SOCIAL_TOKEN_ENCRYPTION_KEY is invalid (must be 32 url-safe "
|
||||||
|
"base64-encoded bytes from Fernet.generate_key()). "
|
||||||
|
"Replace the placeholder in the beta/prod env and restart."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def encrypt_tokens(plaintext: str) -> str:
|
def encrypt_tokens(plaintext: str) -> str:
|
||||||
@@ -25,3 +63,5 @@ def decrypt_tokens(ciphertext: str) -> str:
|
|||||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||||
except InvalidToken as exc:
|
except InvalidToken as exc:
|
||||||
raise ValueError("Unable to decrypt social tokens") from exc
|
raise ValueError("Unable to decrypt social tokens") from exc
|
||||||
|
except SocialCryptoError:
|
||||||
|
raise
|
||||||
|
|||||||
+14
-4
@@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe
|
|||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
|
|
||||||
from messaging.services import parse_scheduled_for
|
from messaging.services import parse_scheduled_for
|
||||||
from social.crypto import encrypt_tokens
|
from social.crypto import SocialCryptoError, encrypt_tokens
|
||||||
from social.models import (
|
from social.models import (
|
||||||
Platform,
|
Platform,
|
||||||
SocialAccount,
|
SocialAccount,
|
||||||
@@ -143,7 +143,11 @@ def account_list(request):
|
|||||||
)
|
)
|
||||||
app.client_id = client_id
|
app.client_id = client_id
|
||||||
if client_secret:
|
if client_secret:
|
||||||
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
try:
|
||||||
|
app.encrypted_client_secret = encrypt_tokens(client_secret)
|
||||||
|
except SocialCryptoError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
return redirect(f"{request.path}?connect=linkedin")
|
||||||
elif not app.encrypted_client_secret:
|
elif not app.encrypted_client_secret:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
@@ -178,12 +182,18 @@ def account_list(request):
|
|||||||
elif platform == Platform.LINKEDIN:
|
elif platform == Platform.LINKEDIN:
|
||||||
token_blob["author_urn"] = external_id
|
token_blob["author_urn"] = external_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
encrypted = encrypt_tokens(json.dumps(token_blob))
|
||||||
|
except SocialCryptoError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
return redirect(f"{request.path}?connect={platform}")
|
||||||
|
|
||||||
account, created = SocialAccount.objects.update_or_create(
|
account, created = SocialAccount.objects.update_or_create(
|
||||||
platform=platform,
|
platform=platform,
|
||||||
external_id=external_id,
|
external_id=external_id,
|
||||||
defaults={
|
defaults={
|
||||||
"label": label,
|
"label": label,
|
||||||
"encrypted_tokens": encrypt_tokens(json.dumps(token_blob)),
|
"encrypted_tokens": encrypted,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"owner": request.user,
|
"owner": request.user,
|
||||||
},
|
},
|
||||||
@@ -273,7 +283,7 @@ def linkedin_oauth_callback(request):
|
|||||||
"owner": request.user,
|
"owner": request.user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except LinkedInOAuthError as exc:
|
except (LinkedInOAuthError, SocialCryptoError) as exc:
|
||||||
messages.error(request, str(exc))
|
messages.error(request, str(exc))
|
||||||
return redirect("social:account_list")
|
return redirect("social:account_list")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user