[fix] solution/backend,frontend: 메일 '고쳐서 올리려면' 링크를 일회용 코드로

주소가 500자였던 것은 곁가지고, 진짜 문제는 그 auto= 가 빌더 액세스 토큰
통짜(sub 에 UserInfo 전체 — role 포함)였다는 것이다. 메일 전달 한 번이 그날
자정까지의 권한 양도였고, 브라우저 히스토리·프록시 로그·Referer 에도 남았다.
SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를
원칙으로 박아 뒀는데 이 경로에만 남아 있었다.

- 0023: place_posts.edit_token_hash. 승인 토큰과 같은 규약(평문은 메일에만)
- GET /v1/site/post/edit?t=<코드> → 검증 후 day-pass 를 그 자리에서 만들어
  /blog?...#auto=<JWT> 로 303. ★ 프래그먼트는 서버 로그·Referer 에 안 남는다
- 프론트는 hash 에서 읽고 **주소창에서 지운다**. 쿼리도 계속 받는다 —
  이미 나간 메일이 자정까지 살아 있고 그걸 깨면 그 링크들이 통째로 죽는다
- 두 코드는 서로 다른 칸에 산다. 하나로 둘 다 되면 일회성이 무의미해진다
- blog_service.app_origin() 으로 오리진 계산을 옮겼다 — 라우터도 같은 값을 쓴다

링크 길이 약 500자 → 약 75자.

test_blog_owner 45 passed (신규 6건: 길이·프래그먼트·소유자·만료·없는 코드·
승인 코드 교차 사용). test_upcoming_only_returns_next_week_in_date_order 1건은
기준(stash)에서도 동일하게 실패하는 기존 건. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-28 09:00:02 +09:00
parent 79962b93e2
commit e35ce311d3
11 changed files with 298 additions and 27 deletions

View File

@ -260,3 +260,28 @@
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다 - 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16 - 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다 대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다
## 메일 링크 — 둘 다 일회용 코드다 (2026-09-28)
```
이대로 올리려면 : /v1/site/post/approve?t=<43자>
고쳐서 올리려면 : /v1/site/post/edit?t=<43자>
```
★ 예전에는 수정 링크가 `/blog?placeId=..&postId=..&auto=<JWT>` 였다. 주소가 500자였던 것은
곁가지고, 진짜 문제는 그 `auto` 가 **빌더 액세스 토큰 통짜**(sub 에 UserInfo 전체 — role 포함)
였다는 것이다 — **메일 전달 한 번이 그날 자정까지의 권한 양도**이고, 브라우저 히스토리 ·
앞단 프록시 로그 · Referer 에도 그대로 남았다.
(SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를 원칙으로
박아 뒀는데, 이 경로에만 남아 있었다.)
지금은 `/edit` 이 코드를 검증한 뒤 **그 자리에서** day-pass 토큰을 만들어
`/blog?placeId=..&postId=..#auto=<JWT>` 로 303 리다이렉트한다.
★ **프래그먼트**로 넘기는 이유: 프래그먼트는 서버 로그와 Referer 에 남지 않는다.
프론트(`app/provider.tsx`)는 그 값을 읽어 세션을 세운 뒤 **주소창에서 지운다.**
★ 쿼리(`?auto=`)도 계속 받는다 — 이미 나간 메일이 자정까지 살아 있고, 그걸 깨면 그 링크들이
통째로 죽는다.
★ 두 코드는 **서로 다른 칸**(`approve_token_hash` · `edit_token_hash`)에 산다. 하나로 둘 다
되면 일회성이 무의미해진다. 만료는 `token_expires_at` 을 같이 쓴다.

View File

@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS public.place_posts (
scheduled_date DATE NULL, -- 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다 scheduled_date DATE NULL, -- 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다
generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다 generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다
approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만 approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만
edit_token_hash VARCHAR(64) NULL, -- '고쳐서 올리려면' 링크의 일회용 코드 해시(migrations/0023)
token_expires_at TIMESTAMPTZ NULL, token_expires_at TIMESTAMPTZ NULL,
sent_at TIMESTAMPTZ NULL, sent_at TIMESTAMPTZ NULL,
approved_at TIMESTAMPTZ NULL, approved_at TIMESTAMPTZ NULL,
@ -571,6 +572,11 @@ CREATE INDEX IF NOT EXISTS ix_place_posts_status
CREATE INDEX IF NOT EXISTS ix_place_posts_token CREATE INDEX IF NOT EXISTS ix_place_posts_token
ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL; ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
-- 메일 '고쳐서 올리려면' 링크가 한 번에 한 행을 집는다(migrations/0023).
CREATE INDEX IF NOT EXISTS ix_place_posts_edit_token
ON public.place_posts(edit_token_hash)
WHERE deleted = false AND edit_token_hash IS NOT NULL;
-- alert_outbox -- alert_outbox
-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것. -- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것.
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at); CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);

View File

@ -0,0 +1,17 @@
-- 0023 · place_posts.edit_token_hash — 메일의 "고쳐서 올리려면" 링크를 일회용 코드로.
--
-- ★ 예전에는 그 링크에 **빌더 액세스 토큰 통짜**(sub 에 UserInfo 전체 — role 포함)를
-- 쿼리로 실어 보냈다. 주소가 500자였던 것은 그 때문이고, 길이보다 나쁜 것은 따로 있다:
-- 메일을 한 번 전달하면 **그날 자정까지 빌더 권한이 그대로 넘어간다.** 브라우저 히스토리 ·
-- 앞단 프록시 로그 · Referer 에도 그대로 남는다.
-- (SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를
-- 설계 원칙으로 박아 뒀는데, 이 경로에만 남아 있었다.)
--
-- ★ 승인 토큰(approve_token_hash)과 같은 규약이다 — 평문은 메일 본문에만 있고 DB 에는
-- sha256 만 둔다. 만료는 token_expires_at 을 같이 쓴다(두 링크가 같은 시각에 죽는다).
ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS edit_token_hash varchar(64);
-- 링크 한 번에 한 행을 집는다. 승인 토큰과 같은 이유로 부분 인덱스다.
CREATE INDEX IF NOT EXISTS ix_place_posts_edit_token
ON public.place_posts(edit_token_hash)
WHERE deleted = false AND edit_token_hash IS NOT NULL;

View File

@ -461,6 +461,9 @@ class place_posts(MainTableMixin, MAIN_BASE):
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다. # 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
generation_meta = Column(JSONB, nullable=True) generation_meta = Column(JSONB, nullable=True)
approve_token_hash = Column(String(64), nullable=True) approve_token_hash = Column(String(64), nullable=True)
# ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을
# 통짜로 실어 보냈다 — 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
edit_token_hash = Column(String(64), nullable=True)
token_expires_at = Column(DateTime(timezone=True), nullable=True) token_expires_at = Column(DateTime(timezone=True), nullable=True)
sent_at = Column(DateTime(timezone=True), nullable=True) sent_at = Column(DateTime(timezone=True), nullable=True)
approved_at = Column(DateTime(timezone=True), nullable=True) approved_at = Column(DateTime(timezone=True), nullable=True)

View File

@ -144,11 +144,20 @@ class PostCRUD:
) )
return result.scalars().first() return result.scalars().first()
async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at) -> ErrorType: async def by_edit_token_hash(self, cdb: AsyncSession, token_hash: str):
result = await cdb.execute(
select(place_posts)
.where(place_posts.edit_token_hash == token_hash, place_posts.deleted == False) # noqa: E712
)
return result.scalars().first()
async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at,
edit_token_hash: str | None = None) -> ErrorType:
await cdb.execute( await cdb.execute(
update(place_posts) update(place_posts)
.where(place_posts.post_id == post_id) .where(place_posts.post_id == post_id)
.values(status=PostStatus.SENT.value, approve_token_hash=token_hash, .values(status=PostStatus.SENT.value, approve_token_hash=token_hash,
edit_token_hash=edit_token_hash,
token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC()) token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC())
) )
return ErrorType.SUCCESS return ErrorType.SUCCESS

View File

@ -19,13 +19,14 @@ from datetime import date
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from common.models.gmodel import Res_WebPacketProtocol, UserInfo from common.models.gmodel import Res_WebPacketProtocol, UserInfo
from router.v1.site.protocol import ( from router.v1.site.protocol import (
Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts, Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
) )
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services import blog_service
from services.post_service import PostService from services.post_service import PostService
router = APIRouter(prefix="/v1/site/post", tags=["Site"]) router = APIRouter(prefix="/v1/site/post", tags=["Site"])
@ -61,6 +62,27 @@ def _expired_page() -> HTMLResponse:
) )
@router.get(path="/edit", summary="수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다")
async def edit_redirect(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
"""메일의 '고쳐서 올리려면'.
★ 액세스 토큰은 **쿼리가 아니라 프래그먼트**로 넘긴다 — 프래그먼트는 서버 로그와 Referer 에
남지 않는다. 예전처럼 쿼리에 실으면 주소가 500자가 되는 것보다, 메일 전달 한 번이
자정까지의 권한 양도가 되는 쪽이 더 나빴다(services/post_service.open_editor)."""
result = await service.open_editor(t)
if not result["success"]:
return _expired_page()
target = (
f"{blog_service.app_origin()}/blog"
f"?placeId={result['place_id']}&postId={result['post_id']}"
f"#auto={result['auto']}"
)
# 303 — 이 주소는 다시 쓸 수 없으니 브라우저가 되돌아오지 않게 한다.
return RedirectResponse(target, status_code=303, headers={
"Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
})
@router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다") @router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다")
async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()): async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
result = await service.decide(t, skip=False) result = await service.decide(t, skip=False)

View File

@ -18,9 +18,7 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_posts, places, sites, users from common.database.model.models import place_posts, places, sites, users
from common.enums import DBWRType, PostStatus, SiteStatus from common.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.post_crud import PostCRUD from crud.post_crud import PostCRUD
from router.v1.validator.dependencies import CreateDayPassToken
from services import blog_service, mail_service, site_payload from services import blog_service, mail_service, site_payload
from services.snapshot import build_snapshot from services.snapshot import build_snapshot
@ -196,15 +194,15 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None
) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 ) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str: def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str, edit_token: str) -> str:
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다 """승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에 (2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는 만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신, 로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다.""" 실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version) # ★ 두 링크가 같은 모양이다 — 일회용 코드 하나씩. 예전에는 수정 링크만 500자였는데,
auto_token = CreateDayPassToken(user_info) # 길이보다 나쁜 것은 거기 실린 빌더 액세스 토큰이었다(services/post_service.open_editor).
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}" edit_link = f"{origin}/v1/site/post/edit?t={edit_token}"
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}" approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
return ( return (
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n" f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
@ -223,30 +221,26 @@ def _notify_address(place, user) -> str:
def _app_origin() -> str: def _app_origin() -> str:
"""메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진. """blog_service.app_origin() 을 그대로 쓴다 — 라우터도 같은 값을 써야 해서 거기로 옮겼다."""
return blog_service.app_origin()
★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/<slug>)
전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면"
링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이
이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다.
비어 있으면(로컬에서 안 채웠으면) publish_origin() 으로 폴백해 링크가 아예 상대경로로
깨지는 것보다는 낫게 한다."""
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
async def _send_one(place, user, post) -> bool: async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다.""" """토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다."""
token, token_hash, expires = blog_service.issue_token() token, token_hash, expires = blog_service.issue_token()
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
edit_token, edit_hash, _edit_expires = blog_service.issue_token()
body = _mail_body( body = _mail_body(
place_name=place.name, post=post, user=user, place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, origin=_app_origin(), approve_token=token, edit_token=edit_token,
) )
ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
if not ok: if not ok:
return False return False
await DB_SESSION_MNG.execute_lambda_run( await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [place_posts.DBType()],
[lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)], [lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
) )
return True return True

View File

@ -10,6 +10,7 @@ import re
import secrets import secrets
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from config import social_config
from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind
from common.logger import LOG from common.logger import LOG
@ -51,6 +52,19 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() return hashlib.sha256(token.encode("utf-8")).hexdigest()
def app_origin() -> str:
"""메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진.
★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/<slug>)
전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면"
링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이
이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다.
비어 있으면 publish_origin() 으로 폴백해 링크가 상대경로로 깨지는 것보다는 낫게 한다."""
from services import site_payload
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
def issue_token() -> tuple[str, str, object]: def issue_token() -> tuple[str, str, object]:
"""(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다. """(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다.

View File

@ -63,6 +63,62 @@ class PostService:
return None return None
return post return post
async def open_editor(self, token: str) -> dict:
"""메일의 '고쳐서 올리려면' — 일회용 코드를 그 자리에서 세션으로 바꾼다.
★ 예전에는 메일 링크에 **빌더 액세스 토큰을 통짜로** 실어 보냈다(sub 에 UserInfo
전체 — role 포함). 주소가 500자였던 것은 곁가지고, 진짜 문제는 **메일 전달 한 번이
그날 자정까지의 권한 양도**였다는 것이다. 브라우저 히스토리·프록시 로그·Referer 에도
그대로 남았다. 이제 URL 에는 일회용 코드만 있고, 토큰은 여기서 만들어 **프래그먼트**로
넘긴다 — 프래그먼트는 서버 로그와 Referer 에 남지 않는다.
★ 실패 이유를 구분해 답하지 않는다(만료·없는 코드·이미 처리됨) — 사장님에게는
'못 쓰는 링크' 하나다(find_by_token 과 같은 규약)."""
from router.v1.validator.dependencies import CreateDayPassToken
token_hash = blog_service.hash_token(token)
post = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_edit_token_hash(s, token_hash),
)
if not post or post.status not in (PostStatus.SENT.value, PostStatus.REVIEWED.value):
return {"success": False}
if post.token_expires_at and post.token_expires_at.replace(tzinfo=None) < GTime.UTC():
return {"success": False}
owner = await self._owner_of(post.place_id)
if owner is None:
return {"success": False}
return {
"success": True,
"place_id": str(post.place_id),
"post_id": str(post.post_id),
"auto": CreateDayPassToken(owner),
}
async def _owner_of(self, place_id) -> UserInfo | None:
"""글이 달린 사업장의 주인. ★ 토큰이 가리키는 글에서 사람을 끌어낸다 —
URL 에 누구인지 싣지 않기 위한 조건이다."""
from common.database.model.models import users
def query(session):
return session.execute(
select(users)
.join(places, places.owner_user_id == users.user_id)
.where(
places.place_id == place_id,
places.deleted == False, # noqa: E712
users.deleted == False, # noqa: E712
)
.limit(1)
)
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
row = result.scalars().first() if result is not None else None
if row is None:
return None
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
async def decide(self, token: str, *, skip: bool) -> dict: async def decide(self, token: str, *, skip: bool) -> dict:
post = await self.find_by_token(token) post = await self.find_by_token(token)
if not post: if not post:

View File

@ -9,7 +9,7 @@
- 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다 - 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다
""" """
import uuid import uuid
from datetime import date, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from sqlalchemy import text from sqlalchemy import text
@ -814,9 +814,12 @@ async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_en
assert sent == 1 assert sent == 1
mail_text = sent_calls[0]["text"] mail_text = sent_calls[0]["text"]
assert "/v1/site/post/approve?t=" in mail_text assert "/v1/site/post/approve?t=" in mail_text
assert "/blog?placeId=" in mail_text # ★ 수정 링크도 일회용 코드다(2026-09-28). 예전에는 여기 빌더 액세스 토큰을 통짜로 실어
assert f"postId={post_id}" in mail_text # `/blog?placeId=..&postId=..&auto=<JWT>` 를 보냈다 — 주소가 500자였던 것은 곁가지고,
assert "auto=" in mail_text # 진짜 문제는 **메일 전달 한 번이 그날 자정까지의 권한 양도**였다는 것이다.
assert "/v1/site/post/edit?t=" in mail_text
assert "auto=" not in mail_text
assert "eyJ" not in mail_text
async def test_mail_links_use_the_builder_app_origin_not_the_published_site_origin( async def test_mail_links_use_the_builder_app_origin_not_the_published_site_origin(
@ -825,14 +828,16 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
"""수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진 """수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진
(site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면 (site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면
404가 난다(2026-09-21 실측: 메일의 '수정하려면' 링크가 거기로 가서 404).""" 404가 난다(2026-09-21 실측: 메일의 '수정하려면' 링크가 거기로 가서 404)."""
from services import blog_jobs, mail_service, site_payload from services import blog_jobs, blog_service, mail_service, site_payload
monkeypatch.setattr(mail_service, "is_configured", lambda: True) monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = [] sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True) monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
monkeypatch.setattr(site_payload, "publish_origin", lambda: "http://published-site-origin") monkeypatch.setattr(site_payload, "publish_origin", lambda: "http://published-site-origin")
# ★ 오리진 계산은 blog_service.app_origin() 으로 옮겼다 — 라우터(수정 링크 리다이렉트)도
# 같은 값을 써야 해서다. 그래서 패치 대상도 거기다.
monkeypatch.setattr( monkeypatch.setattr(
blog_jobs.social_config, "get", blog_service.social_config, "get",
lambda name, default="": "http://builder-app-origin" if name == "SOCIAL_APP_ORIGIN" else default, lambda name, default="": "http://builder-app-origin" if name == "SOCIAL_APP_ORIGIN" else default,
) )
@ -850,7 +855,9 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
assert sent == 1 assert sent == 1
mail_text = sent_calls[0]["text"] mail_text = sent_calls[0]["text"]
assert "http://builder-app-origin/blog?placeId=" in mail_text # 두 링크 다 빌더 앱 오리진이어야 한다. 수정 링크는 일회용 코드를 거쳐 /blog 로
# 리다이렉트되므로(router/v1/site/post.py edit_redirect) 메일에는 /v1/site/post/edit 이 실린다.
assert "http://builder-app-origin/v1/site/post/edit?t=" in mail_text
assert "http://builder-app-origin/v1/site/post/approve?t=" in mail_text assert "http://builder-app-origin/v1/site/post/approve?t=" in mail_text
assert "published-site-origin" not in mail_text assert "published-site-origin" not in mail_text
@ -922,3 +929,113 @@ async def test_generate_one_is_scoped_to_owner(client, db_engine, auth_headers):
) )
assert res.json()["result"]["success"] is False assert res.json()["result"]["success"] is False
# ── 메일 '고쳐서 올리려면' — 일회용 코드 ────────────────────────────────
async def _seed_edit_token(db_engine, post_id, *, expired=False):
from services import blog_service
token, token_hash, expires = blog_service.issue_token()
if expired:
expires = datetime.now(timezone.utc) - timedelta(minutes=1)
aware = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE place_posts SET edit_token_hash=:h, token_expires_at=:e WHERE post_id=:id"),
{"h": token_hash, "e": aware, "id": post_id},
)
return token
async def test_수정_링크는_짧고_액세스_토큰을_싣지_않는다(client, db_engine, auth_headers):
"""★ 예전에는 이 링크에 빌더 액세스 토큰을 통짜로 실었다 — 메일 전달 한 번이
그날 자정까지의 권한 양도였고, 주소는 500자였다."""
h = await auth_headers("blogedit1")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
token = await _seed_edit_token(db_engine, post_id)
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
assert res.status_code == 303
location = res.headers["location"]
# 액세스 토큰은 **프래그먼트**로만 간다 — 서버 로그·Referer 에 남지 않는다.
path_and_query = location.split("#", 1)[0]
assert "auto=" not in path_and_query
assert "#auto=" in location
assert str(post_id) in path_and_query and str(place_id) in path_and_query
assert res.headers["Referrer-Policy"] == "no-referrer"
async def test_수정_링크의_토큰은_그_사장님_것이다(client, db_engine, auth_headers):
"""URL 에 누구인지 싣지 않는다 — 토큰이 가리키는 글에서 사람을 끌어낸다."""
from router.v1.validator.dependencies import DecodeAccessToken
h = await auth_headers("blogedit2")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
token = await _seed_edit_token(db_engine, post_id)
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
auto = res.headers["location"].split("#auto=", 1)[1]
async with db_engine.begin() as conn:
owner = (
await conn.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": place_id})
).scalar_one()
assert DecodeAccessToken(auto).user_id == str(owner)
async def test_만료된_수정_링크는_안_먹는다(client, db_engine, auth_headers):
h = await auth_headers("blogedit3")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
token = await _seed_edit_token(db_engine, post_id, expired=True)
res = await client.get(f"/v1/site/post/edit?t={token}", follow_redirects=False)
assert res.status_code == 200 # 만료 안내 화면
assert "auto=" not in res.text
async def test_없는_수정_코드는_이유를_구분해_답하지_않는다(client, db_engine):
res = await client.get("/v1/site/post/edit?t=ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", follow_redirects=False)
assert res.status_code == 200
assert "auto=" not in res.text
async def test_승인_코드로는_수정_링크를_열_수_없다(client, db_engine, auth_headers):
"""★ 두 코드는 서로 다른 칸에 산다. 하나로 둘 다 되면 일회성이 무의미해진다."""
from services import blog_service
h = await auth_headers("blogedit4")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
approve_token, approve_hash, expires = blog_service.issue_token()
aware = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE place_posts SET approve_token_hash=:h, token_expires_at=:e WHERE post_id=:id"),
{"h": approve_hash, "e": aware, "id": post_id},
)
res = await client.get(f"/v1/site/post/edit?t={approve_token}", follow_redirects=False)
assert res.status_code == 200
assert "auto=" not in res.text
def test_메일_본문에_액세스_토큰이_없다():
"""★ 본문이 곧 유출 경로다 — 전달 한 번으로 권한이 넘어가면 안 된다."""
from types import SimpleNamespace
from services import blog_jobs
post = SimpleNamespace(post_id=uuid.uuid4(), place_id=uuid.uuid4(), body=BODY)
user = SimpleNamespace(user_id=uuid.uuid4(), id="owner", role=1, token_version=1, email="a@b.c")
body = blog_jobs._mail_body(
place_name="테스트", post=post, user=user, origin="https://example.com",
approve_token="APPROVE_CODE", edit_token="EDIT_CODE",
)
assert "APPROVE_CODE" in body and "EDIT_CODE" in body
assert "auto=" not in body # 액세스 토큰이 실리던 자리
assert "eyJ" not in body # JWT 의 머리글자
longest = max(len(word) for word in body.split())
assert longest < 120, f"링크가 아직 길다: {longest}자"

View File

@ -23,11 +23,19 @@ function useRestoreSession() {
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const autoToken = new URLSearchParams(window.location.search).get('auto'); // ★ 프래그먼트(#auto=)를 먼저 본다. 메일의 '고쳐서 올리려면' 링크가 그쪽으로 넘긴다 —
// 프래그먼트는 서버 로그와 Referer 에 남지 않기 때문이다. 쿼리(?auto=)도 계속 받는다:
// 이미 나간 메일이 자정까지 살아 있고, 그걸 깨면 그 링크들이 통째로 죽는다.
const hashToken = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('auto');
const autoToken = hashToken ?? new URLSearchParams(window.location.search).get('auto');
const claims = autoToken ? decodeJwtSubject(autoToken) : null; const claims = autoToken ? decodeJwtSubject(autoToken) : null;
if (autoToken && claims) { if (autoToken && claims) {
setTokens(autoToken); setTokens(autoToken);
setUser({userId: claims.user_id, id: claims.id, role: claims.role}); setUser({userId: claims.user_id, id: claims.id, role: claims.role});
// 주소창에 토큰을 남기지 않는다 — 히스토리·스크린샷·어깨너머로 새는 자리다.
if (hashToken) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
finishRestore(); finishRestore();
return; return;
} }