[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:
parent
4221dad741
commit
ecafa19d00
@ -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로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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, "해제해도 토큰이 남아 있다"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user