[feat] solution/backend,docs: Threads 계정 연동 — 연결 실패 이유를 남기고, 준비 절차를 적는다

자동 게재가 되려면 사장님이 자기 Threads 계정을 연결해야 한다. 연결 코드(인가 URL·코드 교환·
장기 토큰·암호문 저장·해제)는 이미 있었는데, **실제로 연동하려면 무엇을 해야 하는지**가
어디에도 없었고 실패했을 때 이유를 볼 방법도 없었다.

★ 콜백이 예외를 통째로 삼키고 있었다. 화면에는 `?social=failed` 만 뜨고 우리도 원인을 모른다 —
  키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다. 연결이 안 되는데
  로그에 아무것도 없는 것은 이 레포가 가장 싫어하는 종류다.
  → 서버 로그에는 남기고 화면에는 안 내보낸다(OAuth 응답·state 에 자격증명이 들어 있다).
    남기는 것은 예외 종류와 우리가 만든 사유 문자열뿐 — 토큰·code·state 는 찍지 않는다.
    사장님이 인가를 취소한 경우도 고장과 구별되게 따로 남긴다.

- router/v1/social/oauth: 실패 로그 추가(`[social] 계정 연결 실패: …`)
- tests: 가짜 Threads 서버로 **연결 왕복 전체**를 검증한다(코드 교환 → 장기 토큰 → debug_token
  권한 검증 → 저장 → 해제). 실제 연결은 Meta 앱 등록이 끝나야 시험할 수 있는데, 그때 실패하면
  우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 안 된다 — 우리 쪽 왕복은 먼저 못 박는다.
  지키는 것 셋: 저장된 것은 암호문이다 · 권한 검증을 건너뛰지 않는다 · 해제하면 토큰이 지워진다
- docs/SOCIAL.md '연동 준비': Meta 앱 콘솔에서 할 일(제품 추가·리디렉션 URI·권한 둘·
  ★심사 전에는 테스터로 추가된 계정만 인가된다) · 사장님 클릭 흐름 · 실패 시 로그 읽는 법
- .env.example: SOCIAL_*·THREADS_*·ALIMTALK_* 항목과 각각의 "비면 무엇이 꺼지는가"

★ 로컬만으로는 연결을 끝까지 검증할 수 없다 — Meta 는 콜백 URI 를 https 로만 받는다.
  터널로 https 주소를 만들거나 킹서버에서 확인해야 한다. 문서에 적어 뒀다.
★ `SOCIAL_TOKEN_SECRET`(Fernet) 이 없으면 연결 기능 자체가 꺼진다. 이 키를 잃으면 저장된
  토큰을 복호화할 수 없어 전원 재연결이다 — 그 사실도 문서에 적었다.

검증: SNS 테스트 14건 통과(신규 1 — 연결 왕복). 로컬에서 키만 넣고 앱 자격증명이 없는 상태를
확인: connection_enabled=false 로 버튼이 안 뜨고, 강제로 불러도 409 SOCIAL_CONNECTION_DISABLED

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-14 16:08:22 +09:00
parent 4221dad741
commit ecafa19d00
3 changed files with 140 additions and 3 deletions

View File

@ -23,6 +23,57 @@ Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다. - 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다. Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
## 연동 준비 — 한 번만 하는 일
계정 연결은 **두 쪽이 나뉜다.** 우리가 한 번 준비하고(앱 등록), 사장님은 버튼 두 번을 누른다.
### 1. 우리가 한 번 (Meta 앱 콘솔)
1. 개발자 콘솔에서 앱을 만들고 **Threads API** 제품을 추가한다.
★ Threads 자격증명은 페이스북·인스타그램 앱의 것과 **별개**다. Threads 쪽 앱 ID·시크릿을 쓴다.
2. **리디렉션 콜백 URL** 에 `https://<발행호스트>/v1/social/oauth/callback` 을 등록한다.
★ **https 여야 한다.** `http://localhost` 는 콜백으로 등록되지 않는다 — 로컬에서 끝까지
돌려보려면 터널(cloudflared·ngrok)로 https 주소를 만들어 그 주소를 등록하거나,
https 가 붙어 있는 킹서버에서 확인한다. 이 제약 때문에 **연결만은 로컬 단독으로 검증되지 않는다.**
3. 권한은 `threads_basic` · `threads_content_publish` 둘이다(`services/external/threads.py SCOPES`).
4. **심사 전에는 앱 역할에 추가된 계정만 인가된다.** 시험할 사장님 Threads 계정을 테스터로
먼저 추가한다 — 이걸 빼먹으면 인가 화면까지 가서 거절당하고, 화면에는 `?social=failed` 만 뜬다.
5. 루트 `.env` 에 셋을 채우고 백엔드·워커를 다시 띄운다.
```
THREADS_APP_ID=…
THREADS_APP_SECRET=…
THREADS_REDIRECT_URI=https://<발행호스트>/v1/social/oauth/callback
```
★ `SOCIAL_TOKEN_SECRET`(Fernet 키)이 없으면 **연결 기능 자체가 꺼진다.** 평문으로 토큰을
보관하는 길은 만들지 않았다. 만드는 법: `python -c "from cryptography.fernet import Fernet;
print(Fernet.generate_key().decode())"`
★ 이 키를 잃어버리면 저장된 토큰을 복호화할 수 없다 — 모든 사장님이 **다시 연결**해야 한다
(그때 `TOKEN_KEY_CHANGED` 로 `needs_reauth` 가 된다).
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
### 2. 사장님이 하는 일
발행 완료 화면 → **[Threads 계정 연결]** → Meta 인가 화면에서 허용 → 돌아오면 패널에 `@핸들` 이 뜬다.
그 뒤로는 **[소개글 쓰기] → [승인 요청] → 승인** 이 끝이다. 연결은 사람당 한 번이고
(`user_id × provider` 활성 1건), 업장을 여러 개 가져도 계정은 하나다.
### 3. 연결이 안 될 때 — 어디를 보나
콜백은 **화면에 이유를 내보내지 않는다**(OAuth 응답·state 에 자격증명이 들어 있다).
대신 서버 로그에 남는다:
```
docker compose logs -f solution-backend | grep "\[social\]"
[social] 계정 연결 실패: SocialError: INVALID_OAUTH_STATE ← 쿠키 유실·10분 만료
[social] 계정 연결 실패: SocialError: THREADS_REJECTED_400 ← 앱 ID/시크릿·리디렉션 URI 불일치
[social] 계정 연결 중단(제공자 응답): access_denied ← 사장님이 인가를 취소함
```
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
저장하지 않아 **항상 `INVALID_OAUTH_STATE`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
## 보완한 안전장치 ## 보완한 안전장치
**POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다. **POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.

View File

@ -1,6 +1,7 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Request, Response, HTTPException, Query from fastapi import APIRouter, Depends, Request, Response, HTTPException, Query
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken from router.v1.validator.dependencies import IsValidAccessToken
from services import social_account_service as service from services import social_account_service as service
@ -41,9 +42,16 @@ async def callback(
try: try:
await service.finish(state, request.cookies.get(COOKIE), code) await service.finish(state, request.cookies.get(COOKIE), code)
ok = True ok = True
except Exception: except Exception as ex: # noqa: BLE001
# OAuth 응답·state에는 자격증명이 있으므로 예외 원문을 전파하지 않는다. # ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
pass # 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때
# 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다
# (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다).
# ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다.
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
elif error:
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.
LOG.i(f"[social] 계정 연결 중단(제공자 응답): {error[:80]}")
response = RedirectResponse( response = RedirectResponse(
"/sites?social=" + ("connected" if ok else "failed"), status_code=303 "/sites?social=" + ("connected" if ok else "failed"), status_code=303
) )

View File

@ -1,5 +1,6 @@
import json import json
import uuid import uuid
from urllib.parse import parse_qs, urlparse
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx import httpx
import pytest import pytest
@ -344,3 +345,80 @@ async def test_post_claim_prevents_second_external_write(
{"p": post_id}, {"p": post_id},
) )
).scalar_one() == ("UNKNOWN" if unknown else "POSTED") ).scalar_one() == ("UNKNOWN" if unknown else "POSTED")
async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch):
"""검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다.
★ 왜 가짜 서버로 미리 도는가 — 실제 연결은 Meta 앱 등록(리디렉션 URI·권한·테스터 추가)이
끝나야 시험할 수 있다. 그때 실패하면 우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이
안 된다. 우리 쪽 왕복(코드 교환 → 장기토큰 → 검증 → 저장 → 해제)은 여기서 먼저 못 박는다.
★ 이 검사가 지키는 것 셋:
1. 저장된 것은 **암호문**이다 — 토큰 원문이 DB 에 남으면 안 된다.
2. 장기 토큰 교환과 `debug_token` 검증을 건너뛰지 않는다(권한이 모자란 연결을 만들지 않는다).
3. 해제하면 토큰이 **지워진다** — 행만 남기고 토큰을 두면 지운 줄 알고 계속 쓰게 된다.
"""
import json as _json
import uuid as _uuid
from cryptography.fernet import Fernet
from sqlalchemy import text as _text
from services import social_account_service as accounts
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
monkeypatch.setenv("THREADS_APP_ID", "app-1")
monkeypatch.setenv("THREADS_APP_SECRET", "secret-1")
monkeypatch.setenv("THREADS_REDIRECT_URI", "https://example.com/v1/social/oauth/callback")
long_lived = "long-lived-token"
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/access_token"):
return httpx.Response(200, json={"access_token": "short-token", "user_id": "1"})
if path.endswith("/access_token"):
# 장기 토큰 교환. 60일짜리를 준다 — 하루 미만이면 코드가 거절해야 한다.
return httpx.Response(200, json={"access_token": long_lived, "expires_in": 5184000})
if path.endswith("/debug_token"):
return httpx.Response(200, json={"data": {
"is_valid": True, "app_id": "app-1",
"scopes": ["threads_basic", "threads_content_publish"]}})
if path.endswith("/me"):
return httpx.Response(200, json={
"id": "th-1", "username": "mumum", "threads_profile_picture_url": ""})
return httpx.Response(404, json={"error": {"message": "unexpected " + path}})
transport = httpx.MockTransport(handler)
original = httpx.AsyncClient
def fake_client(*args, **kwargs):
kwargs["transport"] = transport
return original(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", fake_client)
user_id = _uuid.uuid4()
url, browser = accounts.begin(user_id, 2)
state = parse_qs(urlparse(url).query)["state"][0]
await accounts.finish(state, browser, "auth-code")
async with db_engine.begin() as conn:
row = (await conn.execute(_text(
"SELECT handle, access_token, status, scopes FROM owner_social_accounts "
"WHERE user_id = :u AND deleted = false"), {"u": user_id})).first()
assert row is not None, "연결이 저장되지 않았다"
assert row.handle == "mumum"
assert row.status == "linked"
# ★ 원문이 DB 에 있으면 안 된다.
assert long_lived not in row.access_token
assert accounts.decrypt(row.access_token) == long_lived
scopes = row.scopes if isinstance(row.scopes, list) else _json.loads(row.scopes)
assert set(scopes) == {"threads_basic", "threads_content_publish"}
await accounts.disconnect(user_id, 2)
async with db_engine.begin() as conn:
after = (await conn.execute(_text(
"SELECT status, access_token FROM owner_social_accounts WHERE user_id = :u"),
{"u": user_id})).first()
assert after.status == "revoked" and after.access_token is None, "해제해도 토큰이 남아 있다"