[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·목록 페이지 — 한 장 규칙을 깬다
- 예약 요청 관리 화면 — `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). 생성 시 순서대로 채운다
generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다
approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만
edit_token_hash VARCHAR(64) NULL, -- '고쳐서 올리려면' 링크의 일회용 코드 해시(migrations/0023)
token_expires_at TIMESTAMPTZ NULL,
sent_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
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
-- 재시도 경로: PENDING(1) 이면서 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)
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)
sent_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()
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(
update(place_posts)
.where(place_posts.post_id == post_id)
.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())
)
return ErrorType.SUCCESS

View File

@ -19,13 +19,14 @@ from datetime import date
from uuid import UUID
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 router.v1.site.protocol import (
Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
)
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services import blog_service
from services.post_service import PostService
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="승인 확정 — 누르는 즉시 게재 큐에 넣는다")
async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
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.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.post_crud import PostCRUD
from router.v1.validator.dependencies import CreateDayPassToken
from services import blog_service, mail_service, site_payload
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 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
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, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version)
auto_token = CreateDayPassToken(user_info)
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}"
# ★ 두 링크가 같은 모양이다 — 일회용 코드 하나씩. 예전에는 수정 링크만 500자였는데,
# 길이보다 나쁜 것은 거기 실린 빌더 액세스 토큰이었다(services/post_service.open_editor).
edit_link = f"{origin}/v1/site/post/edit?t={edit_token}"
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
return (
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
@ -223,30 +221,26 @@ def _notify_address(place, user) -> str:
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() 으로 폴백해 링크가 아예 상대경로로
깨지는 것보다는 낫게 한다."""
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
"""blog_service.app_origin() 을 그대로 쓴다 — 라우터도 같은 값을 써야 해서 거기로 옮겼다."""
return blog_service.app_origin()
async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다."""
token, token_hash, expires = blog_service.issue_token()
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
edit_token, edit_hash, _edit_expires = blog_service.issue_token()
body = _mail_body(
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)
if not ok:
return False
await DB_SESSION_MNG.execute_lambda_run(
[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

View File

@ -10,6 +10,7 @@ import re
import secrets
from datetime import date, datetime, timedelta, timezone
from config import social_config
from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind
from common.logger import LOG
@ -51,6 +52,19 @@ def hash_token(token: str) -> str:
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]:
"""(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다.

View File

@ -63,6 +63,62 @@ class PostService:
return None
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:
post = await self.find_by_token(token)
if not post:

View File

@ -9,7 +9,7 @@
- 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다
"""
import uuid
from datetime import date, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
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
mail_text = sent_calls[0]["text"]
assert "/v1/site/post/approve?t=" in mail_text
assert "/blog?placeId=" in mail_text
assert f"postId={post_id}" in mail_text
assert "auto=" in mail_text
# ★ 수정 링크도 일회용 코드다(2026-09-28). 예전에는 여기 빌더 액세스 토큰을 통짜로 실어
# `/blog?placeId=..&postId=..&auto=<JWT>` 를 보냈다 — 주소가 500자였던 것은 곁가지고,
# 진짜 문제는 **메일 전달 한 번이 그날 자정까지의 권한 양도**였다는 것이다.
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(
@ -825,14 +828,16 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
"""수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진
(site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면
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)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
monkeypatch.setattr(site_payload, "publish_origin", lambda: "http://published-site-origin")
# ★ 오리진 계산은 blog_service.app_origin() 으로 옮겼다 — 라우터(수정 링크 리다이렉트)도
# 같은 값을 써야 해서다. 그래서 패치 대상도 거기다.
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,
)
@ -850,7 +855,9 @@ async def test_mail_links_use_the_builder_app_origin_not_the_published_site_orig
assert sent == 1
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 "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
# ── 메일 '고쳐서 올리려면' — 일회용 코드 ────────────────────────────────
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(() => {
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;
if (autoToken && claims) {
setTokens(autoToken);
setUser({userId: claims.user_id, id: claims.id, role: claims.role});
// 주소창에 토큰을 남기지 않는다 — 히스토리·스크린샷·어깨너머로 새는 자리다.
if (hashToken) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
finishRestore();
return;
}