diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md index 1204b7a..180982c 100644 --- a/docs/MINI_BLOG.md +++ b/docs/MINI_BLOG.md @@ -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=` 였다. 주소가 500자였던 것은 +곁가지고, 진짜 문제는 그 `auto` 가 **빌더 액세스 토큰 통짜**(sub 에 UserInfo 전체 — role 포함) +였다는 것이다 — **메일 전달 한 번이 그날 자정까지의 권한 양도**이고, 브라우저 히스토리 · +앞단 프록시 로그 · Referer 에도 그대로 남았다. +(SNS 승인 흐름에서는 같은 이유로 "기존 액세스 토큰을 승인 링크에 얹지 않는다" 를 원칙으로 +박아 뒀는데, 이 경로에만 남아 있었다.) + +지금은 `/edit` 이 코드를 검증한 뒤 **그 자리에서** day-pass 토큰을 만들어 +`/blog?placeId=..&postId=..#auto=` 로 303 리다이렉트한다. +★ **프래그먼트**로 넘기는 이유: 프래그먼트는 서버 로그와 Referer 에 남지 않는다. +프론트(`app/provider.tsx`)는 그 값을 읽어 세션을 세운 뒤 **주소창에서 지운다.** + +★ 쿼리(`?auto=`)도 계속 받는다 — 이미 나간 메일이 자정까지 살아 있고, 그걸 깨면 그 링크들이 +통째로 죽는다. + +★ 두 코드는 **서로 다른 칸**(`approve_token_hash` · `edit_token_hash`)에 산다. 하나로 둘 다 +되면 일회성이 무의미해진다. 만료는 `token_expires_at` 을 같이 쓴다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 52e94df..4672fec 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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); diff --git a/postgres-init/migrations/0023_place_posts_edit_token.sql b/postgres-init/migrations/0023_place_posts_edit_token.sql new file mode 100644 index 0000000..ddfc08e --- /dev/null +++ b/postgres-init/migrations/0023_place_posts_edit_token.sql @@ -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; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 592342b..0e32102 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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) diff --git a/solution/backend/crud/post_crud.py b/solution/backend/crud/post_crud.py index 10636a7..9efc2b0 100644 --- a/solution/backend/crud/post_crud.py +++ b/solution/backend/crud/post_crud.py @@ -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 diff --git a/solution/backend/router/v1/site/post.py b/solution/backend/router/v1/site/post.py index 08fde0e..79e02b1 100644 --- a/solution/backend/router/v1/site/post.py +++ b/solution/backend/router/v1/site/post.py @@ -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) diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index b915680..b439ff7 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -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/) - 전용이다. 로컬에선 그게 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 diff --git a/solution/backend/services/blog_service.py b/solution/backend/services/blog_service.py index 49eb017..510f91b 100644 --- a/solution/backend/services/blog_service.py +++ b/solution/backend/services/blog_service.py @@ -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/) + 전용이다. 로컬에선 그게 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 에는 해시만 둔다. diff --git a/solution/backend/services/post_service.py b/solution/backend/services/post_service.py index c571eb9..a6a0941 100644 --- a/solution/backend/services/post_service.py +++ b/solution/backend/services/post_service.py @@ -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: diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index e8d7eef..14e9e63 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -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=` 를 보냈다 — 주소가 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}자" diff --git a/solution/frontend/src/app/provider.tsx b/solution/frontend/src/app/provider.tsx index 4a45ffa..58bdbca 100644 --- a/solution/frontend/src/app/provider.tsx +++ b/solution/frontend/src/app/provider.tsx @@ -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; }