From ecafa19d00a0c1c6078cbd7f5d2fd849d3caa779 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 14 Sep 2026 16:08:22 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,docs:=20Threads=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20=E2=80=94=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=8B=A4=ED=8C=A8=20=EC=9D=B4=EC=9C=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0,=20=EC=A4=80=EB=B9=84=20=EC=A0=88?= =?UTF-8?q?=EC=B0=A8=EB=A5=BC=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 게재가 되려면 사장님이 자기 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) --- docs/SOCIAL.md | 51 ++++++++++++++ solution/backend/router/v1/social/oauth.py | 14 +++- solution/backend/tests/test_social.py | 78 ++++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/docs/SOCIAL.md b/docs/SOCIAL.md index 6a216ee..10452d7 100644 --- a/docs/SOCIAL.md +++ b/docs/SOCIAL.md @@ -23,6 +23,57 @@ Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권 - 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다. 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로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다. diff --git a/solution/backend/router/v1/social/oauth.py b/solution/backend/router/v1/social/oauth.py index c47224c..6592a0e 100644 --- a/solution/backend/router/v1/social/oauth.py +++ b/solution/backend/router/v1/social/oauth.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Request, Response, HTTPException, Query from fastapi.responses import RedirectResponse +from common.logger import LOG from common.models.gmodel import UserInfo from router.v1.validator.dependencies import IsValidAccessToken from services import social_account_service as service @@ -41,9 +42,16 @@ async def callback( try: await service.finish(state, request.cookies.get(COOKIE), code) ok = True - except Exception: - # OAuth 응답·state에는 자격증명이 있으므로 예외 원문을 전파하지 않는다. - pass + except Exception as ex: # noqa: BLE001 + # ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다. + # 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때 + # 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다 + # (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다). + # ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다. + LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}") + elif error: + # 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다. + LOG.i(f"[social] 계정 연결 중단(제공자 응답): {error[:80]}") response = RedirectResponse( "/sites?social=" + ("connected" if ok else "failed"), status_code=303 ) diff --git a/solution/backend/tests/test_social.py b/solution/backend/tests/test_social.py index ed36921..5d7be8b 100644 --- a/solution/backend/tests/test_social.py +++ b/solution/backend/tests/test_social.py @@ -1,5 +1,6 @@ import json import uuid +from urllib.parse import parse_qs, urlparse from datetime import datetime, timedelta, timezone import httpx import pytest @@ -344,3 +345,80 @@ async def test_post_claim_prevents_second_external_write( {"p": post_id}, ) ).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, "해제해도 토큰이 남아 있다"