o2o-site-AEO/solution/backend/services/social_account_service.py
hbyang 414c827e38 [fix] solution,docs: 게시가 리디렉션 URI 를 기다리던 것 — 연결과 게시의 전제를 가른다
토큰은 받았는데 리디렉션 URI 가 아직 없다고 **게시까지 막혔다**(실측 2026-09-15).
`posting_enabled` 가 `accounts.configured()`(앱 ID·시크릿·리디렉션 URI 셋)에 묶여 있었는데,
게시는 그 셋 중 **하나도 쓰지 않는다** — `threads.publish(text, token)` 은 사장님 토큰만 쓴다.
앱 자격증명은 계정을 **새로 연결할 때만** 필요하다.

- social_account_service.token_ready(): 토큰을 풀 수 있는가 = 게시의 전제
- posting_enabled(): token_ready + SOCIAL_POSTING_ENABLED. 연결 설정을 묻지 않는다
- 카드 문구: '연결돼 있음' 이 '연결 설정 준비됨' 보다 앞선다. 이미 연결된 계정이 있는데
  "준비 중" 이라고 말하면 사장님은 연결이 풀린 줄 알고, 게시는 저장된 토큰만으로 되므로
  그 말이 사실도 아니다
- docs/SOCIAL.md: 연결·게시 전제 표 + ★ 리디렉션 URI 는 Meta 가 발급하는 값이 아니라
  우리가 정해 등록하는 우리 주소(`https://<SITE_PUBLIC_HOST>/v1/social/oauth/callback`)
- .env.example: 같은 설명을 값 옆에

검증: SNS 테스트 14건 통과. 로컬에서 테스트 계정 토큰을 넣어 `account.handle=geumnamsijang ·
connection_enabled=true · posting_enabled=false`(스위치가 잠긴 상태) 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:32:52 +09:00

197 lines
6.7 KiB
Python

"""위임 토큰은 암호문만 저장한다. 갱신·연결 해제·게시가 같은 계정 잠금을 사용한다."""
import json
from config import social_config as config
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from uuid import UUID
import httpx
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import select, update, text
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_social_accounts as Account
from services.external.social import adapter, SocialError
def cipher():
try:
return Fernet(config.required("SOCIAL_TOKEN_SECRET").encode())
except (KeyError, ValueError) as ex:
raise SocialError("SOCIAL_CONNECTION_DISABLED") from ex
def token_ready():
"""저장된 토큰을 풀 수 있는가 — **게시의 전제**다.
★ 연결(OAuth)과 전제가 다르다. 연결은 앱 자격증명 셋(ID·시크릿·리디렉션 URI)이 있어야
시작할 수 있지만, **게시는 이미 받아 둔 사장님 토큰 하나면 된다**
(`threads.publish(text, token)` 이 그 토큰만 쓴다).
둘을 한 깃발로 묶으면 리디렉션 URI 가 아직 없다는 이유로 **게시까지 막힌다** —
실제로 그랬다(2026-09-15: 토큰은 있고 URI 만 없는데 게시가 열리지 않았다).
"""
try:
cipher()
return True
except SocialError:
return False
def configured(provider=2):
try:
cipher()
return adapter(provider).is_configured()
except SocialError:
return False
def encrypt(value):
return cipher().encrypt(value.encode()).decode()
def decrypt(value):
try:
return cipher().decrypt(value.encode()).decode()
except (InvalidToken, AttributeError) as ex:
raise SocialError("TOKEN_KEY_CHANGED", reauth=True) from ex
def begin(user_id, provider):
if not configured(provider):
raise SocialError("SOCIAL_CONNECTION_DISABLED")
browser = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(32)
# 서명만 된 state는 PKCE verifier를 URL로 공개한다. 암호화하고 브라우저 쿠키에도 묶는다.
state = encrypt(
json.dumps(
{
"u": str(user_id),
"p": provider,
"v": verifier,
"b": hashlib.sha256(browser.encode()).hexdigest(),
}
)
)
return adapter(provider).authorize_url(state, verifier), browser
async def finish(state, browser, code):
try:
data = json.loads(cipher().decrypt(state.encode(), ttl=600))
if not browser or not secrets.compare_digest(
data["b"], hashlib.sha256(browser.encode()).hexdigest()
):
raise ValueError()
user_id = UUID(data["u"])
provider = int(data["p"])
except (InvalidToken, ValueError, KeyError, TypeError) as ex:
raise SocialError("INVALID_OAUTH_STATE") from ex
async with httpx.AsyncClient(timeout=20) as client:
token = await adapter(provider).exchange(code, data["v"], client=client)
profile = await adapter(provider).me(token["access_token"], client=client)
async def save(s):
# 업장별 연결 해제로 다른 업장의 토큰이 살아남지 않도록 사람+플랫폼 단위로 잠근다.
await lock_user(s, user_id, provider)
await s.execute(
update(Account)
.where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
)
.values(status="revoked", access_token=None, refresh_token=None)
) # noqa: E712
s.add(
Account(
user_id=user_id,
provider=provider,
provider_user_id=profile["id"],
handle=profile["handle"],
profile_url=profile["profile_url"],
access_token=encrypt(token["access_token"]),
refresh_token=encrypt(token["refresh_token"])
if token.get("refresh_token")
else None,
scopes=token["scope"].split(),
status="linked",
access_expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=int(token["expires_in"])),
)
)
await DB_SESSION_MNG.execute_lambda_write(Account.DBType(), save)
async def lock_user(s, user_id, provider):
await s.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
{"key": f"social:{user_id}:{provider}"},
)
async def account(s, user_id, provider):
return (
await s.execute(
select(Account).where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
Account.status.in_(["linked", "needs_reauth"]),
)
)
).scalar_one_or_none() # noqa: E712
async def get_usable_token(s, row, client):
if row.status != "linked":
raise SocialError("ACCOUNT_NEEDS_REAUTH", reauth=True)
token = decrypt(row.access_token)
if row.access_expires_at and row.access_expires_at > datetime.now(
timezone.utc
) + timedelta(minutes=5):
return token
result = await adapter(row.provider).refresh(
decrypt(row.refresh_token) if row.refresh_token else token, client=client
)
# 회전 토큰 저장은 한 UPDATE. 호출측은 이 트랜잭션을 커밋한 뒤에만 게시를 시작한다.
await s.execute(
update(Account)
.where(Account.account_id == row.account_id)
.values(
access_token=encrypt(result["access_token"]),
refresh_token=encrypt(result["refresh_token"])
if result.get("refresh_token")
else None,
access_expires_at=datetime.now(timezone.utc)
+ timedelta(seconds=int(result["expires_in"])),
scopes=result["scope"].split(),
updated_at=datetime.now(timezone.utc),
last_error=None,
)
)
return result["access_token"]
async def disconnect(user_id, provider):
async def run(s):
await lock_user(s, user_id, provider)
await s.execute(
update(Account)
.where(
Account.user_id == user_id,
Account.provider == provider,
Account.deleted == False,
)
.values(
status="revoked",
access_token=None,
refresh_token=None, # noqa: E712
updated_at=datetime.now(timezone.utc),
)
)
await DB_SESSION_MNG.execute_lambda_write(Account.DBType(), run)