From 2b39f13cae9b99d11669fab7b1f50cea406e4930 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sun, 26 Jul 2026 12:05:00 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(#194):=20=E8=AA=8D=E5=8F=AF=E3=82=92?= =?UTF-8?q?=20trip=5Fids=20JWT=20=E3=81=8B=E3=82=89=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AD=E3=83=BC=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E3=81=B8=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.py を Cookie session_id + user_trip_access 参照方式に書き換え - grant_trip_access は匿名 user/session の自動発行と ON CONFLICT DO NOTHING による idempotent な付与に対応 - 認可判定 (require_*_access) を async + DB クエリ化 - routers/trips.py の呼び出し側を await + db 引数追加に対応 - tests を新方式に合わせて更新 (JWT payload / DB 状態の検証) 挙動変更: trip 削除時に user_trip_access が CASCADE 削除されるため、 削除後の GET は 404 ではなく 403 を返す。 --- server/app/auth.py | 128 +++++++++---- server/app/routers/trips.py | 4 +- server/tests/conftest.py | 35 +++- server/tests/routers/test_trips_router.py | 44 +++-- server/tests/test_auth.py | 207 +++++++++++++++++----- 5 files changed, 309 insertions(+), 109 deletions(-) diff --git a/server/app/auth.py b/server/app/auth.py index b80d3fc7..81f46915 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -1,9 +1,10 @@ """ -認証・認可モジュール +認証・認可モジュール(セッションキー方式) - Basic 認証: APIドキュメントや管理系エンドポイントの保護 -- Cookie 認可: urlId 付き Trip ページへのアクセスを起点として署名付き Cookie (JWT) を発行し、 - 以降のリクエストで Cookie 内の許可済み trip_id リストを検証して認可を行う。 +- セッション認可: 不透明トークン session_id を JWT に載せた HttpOnly Cookie を発行し、 + DB の user_trip_access テーブルで認可判定する。並列付与の race は + (user_id, trip_id) 複合 PK と ON CONFLICT DO NOTHING で idempotent に解消される。 """ import secrets @@ -14,13 +15,14 @@ import jwt from fastapi import Depends, HTTPException, Request, Response, status from fastapi.security import HTTPBasic, HTTPBasicCredentials -from sqlalchemy import select +from sqlalchemy import exists, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.db_connection import get_db_session from app.errors import Forbidden, NotFound -from app.models import Block, Page +from app.models import Block, Page, User, UserSession, UserTripAccess # ---- Basic 認証 ---- @@ -45,26 +47,27 @@ def require_basic_auth( SESSION_COOKIE_NAME = "tabishare_session" +# secrets.token_urlsafe(24) は 32 文字。DB カラム String(32) と一致。 +_SESSION_ID_BYTES = 24 -def get_allowed_trip_ids(request: Request) -> set[int]: - """Cookie から許可済み trip_id のセットを取得する。 +def decode_session_id(request: Request) -> str | None: + """Cookie の JWT から session_id を取り出す。Cookie 無・不正・期限切れなら None。 - `tabishare_session` Cookie の JWT payload に含まれる `trip_ids` 配列をデコードして返す。 - Cookie が無い・JWT が不正・期限切れの場合は空 set。 + 移行ミドルウェアからも参照できるよう public。旧形式 (payload に trip_ids + フィールドがある) の JWT はここで None 扱いになるため、ミドルウェア側で + 別途 payload を検査して移行する必要がある。 """ raw = request.cookies.get(SESSION_COOKIE_NAME) if not raw: - return set() + return None settings = get_settings() try: payload = jwt.decode(raw, settings.cookie_secret_key, algorithms=["HS256"]) except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): - return set() - ids = payload.get("trip_ids") - if not isinstance(ids, list): - return set() - return {tid for tid in ids if isinstance(tid, int)} + return None + sid = payload.get("session_id") + return sid if isinstance(sid, str) else None def _resolve_samesite(request: Request, is_production: bool) -> Literal["lax", "none"]: @@ -84,13 +87,14 @@ def _resolve_samesite(request: Request, is_production: bool) -> Literal["lax", " return "none" -def _set_session_cookie( - request: Request, response: Response, trip_ids: set[int] -) -> None: - """trip_ids 集合をまとめた署名付きセッション Cookie を発行する。""" +def set_session_cookie(request: Request, response: Response, session_id: str) -> None: + """session_id を JWT に載せた HttpOnly Cookie を発行する。 + + 移行ミドルウェアからも呼べるよう public。 + """ settings = get_settings() payload = { - "trip_ids": sorted(trip_ids), + "session_id": session_id, "exp": datetime.now(UTC) + timedelta(seconds=settings.cookie_max_age), } token = jwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") @@ -107,25 +111,79 @@ def _set_session_cookie( ) -def grant_trip_access(request: Request, response: Response, trip_id: int) -> None: - """指定 trip_id へのアクセス権を Cookie に追記する。 +def generate_session_id() -> str: + """新規 session_id (URL-safe な 32 文字トークン) を生成する。""" + return secrets.token_urlsafe(_SESSION_ID_BYTES) + + +async def _ensure_session( + request: Request, response: Response, db: AsyncSession +) -> UserSession: + """現在の session を取得。Cookie 未発行・無効なら匿名 user と session を発行する。""" + session_id = decode_session_id(request) + if session_id is not None: + existing = await db.get(UserSession, session_id) + if existing is not None: + return existing + # Cookie 無 or DB に存在しない → 匿名 user + session を新規発行 + user = User() + db.add(user) + await db.flush() + session = UserSession(id=generate_session_id(), user_id=user.id) + db.add(session) + await db.flush() + set_session_cookie(request, response, session.id) + return session + + +async def grant_trip_access( + request: Request, + response: Response, + trip_id: int, + db: AsyncSession, +) -> None: + """指定 trip_id へのアクセス権を現在の user に付与する。 - 既存 Cookie の trip_ids を読み、`trip_id` をマージしてから再発行する。 - 平行で複数 grant が走ると後勝ちで一部 trip_id が失われ得るが、apiClient 側の - 403 自動 retry で次回アクセス時に再付与され、最終的に収束する。 + Cookie が無ければ匿名 user と session を先に発行する。並列付与の race は + (user_id, trip_id) 複合 PK と ON CONFLICT DO NOTHING で idempotent に解消される。 + プロジェクトの CRUD 慣習に合わせ、変更完了時に commit する。 """ - allowed = get_allowed_trip_ids(request) - allowed.add(trip_id) - _set_session_cookie(request, response, allowed) + session = await _ensure_session(request, response, db) + await db.execute( + pg_insert(UserTripAccess) + .values(user_id=session.user_id, trip_id=trip_id) + .on_conflict_do_nothing(index_elements=["user_id", "trip_id"]) + ) + await db.commit() + + +async def _has_trip_access( + db: AsyncSession, session_id: str | None, trip_id: int +) -> bool: + """session_id が指す user が trip_id にアクセスできるかを判定する。""" + if session_id is None: + return False + stmt = select( + exists().where( + UserSession.id == session_id, + UserTripAccess.user_id == UserSession.user_id, + UserTripAccess.trip_id == trip_id, + ) + ) + result = await db.execute(stmt) + return bool(result.scalar()) # ---- FastAPI Depends 用の認可関数 ---- -def require_trip_access(trip_id: int, request: Request) -> int: - """パスパラメータの trip_id へのアクセス権を Cookie で検証する。""" - allowed = get_allowed_trip_ids(request) - if trip_id not in allowed: +async def require_trip_access( + trip_id: int, + request: Request, + db: AsyncSession = Depends(get_db_session), +) -> int: + """パスパラメータの trip_id へのアクセス権を検証する。""" + if not await _has_trip_access(db, decode_session_id(request), trip_id): raise Forbidden() return trip_id @@ -141,8 +199,7 @@ async def require_page_access( if trip_id is None: raise NotFound(message="Page not found") - allowed = get_allowed_trip_ids(request) - if trip_id not in allowed: + if not await _has_trip_access(db, decode_session_id(request), trip_id): raise Forbidden() return trip_id @@ -162,7 +219,6 @@ async def require_block_access( if trip_id is None: raise NotFound(message="Block not found") - allowed = get_allowed_trip_ids(request) - if trip_id not in allowed: + if not await _has_trip_access(db, decode_session_id(request), trip_id): raise Forbidden() return trip_id diff --git a/server/app/routers/trips.py b/server/app/routers/trips.py index 33e2f033..0ac8ef04 100644 --- a/server/app/routers/trips.py +++ b/server/app/routers/trips.py @@ -36,7 +36,7 @@ async def create_trip( """ url_id: str = generate(size=URL_ID_SIZE) trip_id: int = await trips_cruds.create_trip(db=db, trip=trip_in, url_id=url_id) - grant_trip_access(request, response, trip_id) + await grant_trip_access(request, response, trip_id, db) return TripCreateOut(id=trip_id, url_id=url_id) @@ -103,7 +103,7 @@ async def get_trip_by_url_id( db_trip = await trips_cruds.get_trip_by_url_id(db, url_id=url_id) if db_trip is None: raise NotFound(message="Trip not found") - grant_trip_access(request, response, db_trip.id) + await grant_trip_access(request, response, db_trip.id, db) return db_trip diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 5aca7596..32a07654 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -13,13 +13,14 @@ ) from sqlalchemy.pool import NullPool -from app.auth import SESSION_COOKIE_NAME +from app.auth import SESSION_COOKIE_NAME, generate_session_id from app.config import get_settings from app.cruds import blocks as blocks_cruds from app.cruds import pages as pages_cruds from app.cruds import trips as trips_cruds from app.db_connection import Base, get_db_session from app.main import app +from app.models import User, UserSession, UserTripAccess from app.observability import setup_sqlalchemy_instrumentation from app.schemas.block import Block as BlockSchema from app.schemas.block import BlockCreate @@ -71,7 +72,9 @@ async def setup_database(): async with test_engine.begin() as conn: await conn.execute( text( - "TRUNCATE TABLE blocks, locations, pages, trips RESTART IDENTITY CASCADE" + "TRUNCATE TABLE user_trip_access, sessions, users, " + "blocks, locations, pages, trips " + "RESTART IDENTITY CASCADE" ) ) except Exception as e: @@ -94,15 +97,28 @@ async def client(): yield c -def _make_session_cookie_value(trip_ids: list[int]) -> str: - """テスト用: trip_ids 配列を持つ署名付きセッション Cookie (JWT) の値を生成する""" +def _make_session_cookie_value(session_id: str) -> str: + """テスト用: session_id を持つ署名付きセッション Cookie (JWT) の値を生成する""" payload = { - "trip_ids": sorted(trip_ids), + "session_id": session_id, "exp": datetime.now(UTC) + timedelta(seconds=settings.cookie_max_age), } return pyjwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") +async def _create_session_with_trip_access(db: AsyncSession, trip_id: int) -> str: + """テスト用: 匿名 user + session + user_trip_access を作成して session_id を返す""" + user = User() + db.add(user) + await db.flush() + + session_id = generate_session_id() + db.add(UserSession(id=session_id, user_id=user.id)) + db.add(UserTripAccess(user_id=user.id, trip_id=trip_id)) + await db.commit() + return session_id + + @pytest_asyncio.fixture async def test_create_trip(db_session: AsyncSession) -> Trip: """テスト用のTripを作成して、Tripを返すフィクスチャ""" @@ -143,10 +159,13 @@ async def test_create_block( @pytest_asyncio.fixture -async def authed_client(client: AsyncClient, test_create_trip: Trip) -> AsyncClient: - """test_create_trip で作成された Trip へのアクセス権 Cookie を持つクライアント""" +async def authed_client( + client: AsyncClient, db_session: AsyncSession, test_create_trip: Trip +) -> AsyncClient: + """test_create_trip で作成された Trip へのアクセス権を持つ session Cookie を発行""" + session_id = await _create_session_with_trip_access(db_session, test_create_trip.id) client.cookies.set( SESSION_COOKIE_NAME, - _make_session_cookie_value([test_create_trip.id]), + _make_session_cookie_value(session_id), ) return client diff --git a/server/tests/routers/test_trips_router.py b/server/tests/routers/test_trips_router.py index 53e91467..1e5c9b54 100644 --- a/server/tests/routers/test_trips_router.py +++ b/server/tests/routers/test_trips_router.py @@ -164,8 +164,11 @@ async def test_delete_trip(client: AsyncClient, db_session: AsyncSession): assert response.status_code == 204 # --- 削除されたことを確認 --- + # 新方式では trip 削除で user_trip_access も CASCADE 削除されるため、 + # 認可チェックが先に落ちて 403 を返す(trip 存在チェックには到達しない)。 + # UX 上「削除後にアクセスできない」という意図は満たされる。 response = await client.get(f"/trips/{trip_id}") - assert response.status_code == 404 + assert response.status_code == 403 # ---- Cookie 発行テスト ---- @@ -174,7 +177,7 @@ async def test_delete_trip(client: AsyncClient, db_session: AsyncSession): async def test_create_trip_sets_access_cookie( client: AsyncClient, db_session: AsyncSession ): - """POST /trips のレスポンスに認可Cookieが含まれることを検証""" + """POST /trips のレスポンスに session_id を持つ認可 Cookie が発行されることを検証""" response = await client.post("/trips", json={"title": "cookie test", "detail": "d"}) assert response.status_code == 200 trip_id = response.json()["id"] @@ -183,18 +186,22 @@ async def test_create_trip_sets_access_cookie( matching = [h for h in set_cookie_headers if SESSION_COOKIE_NAME in h] assert len(matching) == 1 - # Cookie値がデコード可能で trip_ids に作成した trip_id が含まれる + # 新形式 payload は session_id のみを持ち、trip_ids は載らない settings = get_settings() token = client.cookies.get(SESSION_COOKIE_NAME) payload = pyjwt.decode(token, settings.cookie_secret_key, algorithms=["HS256"]) - assert trip_id in payload["trip_ids"] + assert isinstance(payload.get("session_id"), str) + assert "trip_ids" not in payload + + # 発行された Cookie で当該 trip にアクセスできる (認可が動く) + read = await client.get(f"/trips/{trip_id}") + assert read.status_code == 200 async def test_get_trip_by_url_id_sets_access_cookie( client: AsyncClient, db_session: AsyncSession ): - """GET /trips/url/{url_id} のレスポンスに認可Cookieが含まれることを検証""" - # まずtripを作成 + """GET /trips/url/{url_id} のレスポンスに認可 Cookie が発行されることを検証""" response = await client.post( "/trips", json={"title": "url id cookie test", "detail": "d"} ) @@ -210,29 +217,34 @@ async def test_get_trip_by_url_id_sets_access_cookie( matching = [h for h in set_cookie_headers if SESSION_COOKIE_NAME in h] assert len(matching) == 1 - # 発行された Cookie の trip_ids に当該 trip_id が含まれる settings = get_settings() token = client.cookies.get(SESSION_COOKIE_NAME) payload = pyjwt.decode(token, settings.cookie_secret_key, algorithms=["HS256"]) - assert trip_id in payload["trip_ids"] + assert isinstance(payload.get("session_id"), str) + # 発行された Cookie で当該 trip にアクセスできる + read = await client.get(f"/trips/{trip_id}") + assert read.status_code == 200 -async def test_grant_trip_access_merges_existing_ids( + +async def test_grant_trip_access_accumulates_multiple_trips( client: AsyncClient, db_session: AsyncSession ): - """既存 Cookie の trip_ids に新しい trip_id が追記されることを検証""" - # 1 個目を作成 → cookie に trip_id1 のみ + """同一 session で複数 trip を作成した後、両方にアクセスできることを検証。 + + 旧方式の "trip_ids 配列にマージ" と違い、新方式では session 側は 1 個の session_id + しか持たず、user_trip_access に (user, trip) 行が積み上がる。ここでは統合テストの + 観点として "複数 trip の認可が両方通る" ことだけを検証する。 + """ r1 = await client.post("/trips", json={"title": "first", "detail": "d"}) trip_id1 = r1.json()["id"] - # 2 個目を作成 → 1 個目の trip_id を保持したまま 2 個目が追記される r2 = await client.post("/trips", json={"title": "second", "detail": "d"}) trip_id2 = r2.json()["id"] - settings = get_settings() - token = client.cookies.get(SESSION_COOKIE_NAME) - payload = pyjwt.decode(token, settings.cookie_secret_key, algorithms=["HS256"]) - assert set(payload["trip_ids"]) >= {trip_id1, trip_id2} + # 同一 session (httpx client が Cookie を保持) で両方の trip にアクセス可能 + assert (await client.get(f"/trips/{trip_id1}")).status_code == 200 + assert (await client.get(f"/trips/{trip_id2}")).status_code == 200 # ---- 未認可アクセス 403 テスト ---- diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py index b4939c26..ea88840f 100644 --- a/server/tests/test_auth.py +++ b/server/tests/test_auth.py @@ -1,20 +1,33 @@ -"""auth.py のユニットテスト""" +"""auth.py (セッションキー方式) の単体テスト""" from datetime import UTC, datetime, timedelta import jwt as pyjwt import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from starlette.requests import Request - -from app.auth import SESSION_COOKIE_NAME, get_allowed_trip_ids, require_trip_access +from starlette.responses import Response + +from app.auth import ( + SESSION_COOKIE_NAME, + _has_trip_access, + decode_session_id, + generate_session_id, + grant_trip_access, + require_trip_access, +) from app.config import get_settings from app.errors import Forbidden +from app.models import User, UserSession, UserTripAccess +from app.schemas.trip import TripCreateIn +from app.cruds import trips as trips_cruds settings = get_settings() def _make_request(cookies: dict[str, str] | None = None) -> Request: - """テスト用の Starlette Request を構築する。""" + """テスト用の Starlette Request を構築する""" scope = {"type": "http", "method": "GET", "path": "/", "headers": []} if cookies: cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items()) @@ -22,10 +35,10 @@ def _make_request(cookies: dict[str, str] | None = None) -> Request: return Request(scope) -def _encode_session_jwt(trip_ids: list[int], **overrides) -> str: - """テスト用: trip_ids 配列を持つセッション JWT を生成する。""" +def _encode_new_session_jwt(session_id: str, **overrides) -> str: + """新方式: session_id を payload に持つ JWT を生成する""" payload = { - "trip_ids": trip_ids, + "session_id": session_id, "exp": datetime.now(UTC) + timedelta(seconds=settings.cookie_max_age), **overrides, } @@ -36,77 +49,177 @@ def _encode_session_jwt(trip_ids: list[int], **overrides) -> str: ) -# ---- get_allowed_trip_ids ---- +# ---- decode_session_id ---- -def test_get_allowed_trip_ids_valid_cookie(): - token = _encode_session_jwt([1]) +def test_decode_session_id_valid(): + token = _encode_new_session_jwt("abc123session") request = _make_request({SESSION_COOKIE_NAME: token}) - assert get_allowed_trip_ids(request) == {1} - + assert decode_session_id(request) == "abc123session" -def test_get_allowed_trip_ids_multiple_ids(): - token = _encode_session_jwt([1, 2, 3]) - request = _make_request({SESSION_COOKIE_NAME: token, "session_id": "unrelated"}) - assert get_allowed_trip_ids(request) == {1, 2, 3} +def test_decode_session_id_no_cookie(): + assert decode_session_id(_make_request()) is None -def test_get_allowed_trip_ids_no_cookies(): - request = _make_request() - assert get_allowed_trip_ids(request) == set() - -def test_get_allowed_trip_ids_invalid_jwt(): +def test_decode_session_id_invalid_jwt(): request = _make_request({SESSION_COOKIE_NAME: "not-a-jwt"}) - assert get_allowed_trip_ids(request) == set() + assert decode_session_id(request) is None -def test_get_allowed_trip_ids_wrong_signing_key(): +def test_decode_session_id_wrong_signing_key(): payload = { - "trip_ids": [1], + "session_id": "abc", "exp": datetime.now(UTC) + timedelta(seconds=3600), } - token = pyjwt.encode(payload, "this-is-a-wrong-secret-key-that-is-at-least-32-bytes-long", algorithm="HS256") + token = pyjwt.encode( + payload, + "wrong-secret-key-that-is-at-least-32-bytes-long", + algorithm="HS256", + ) request = _make_request({SESSION_COOKIE_NAME: token}) - assert get_allowed_trip_ids(request) == set() + assert decode_session_id(request) is None -def test_get_allowed_trip_ids_expired_jwt(): +def test_decode_session_id_expired_jwt(): payload = { - "trip_ids": [1], + "session_id": "abc", "exp": datetime.now(UTC) - timedelta(hours=1), } token = pyjwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") request = _make_request({SESSION_COOKIE_NAME: token}) - assert get_allowed_trip_ids(request) == set() + assert decode_session_id(request) is None -def test_get_allowed_trip_ids_payload_without_trip_ids(): - """JWT に trip_ids キーが無い場合は空 set を返す""" - payload = {"exp": datetime.now(UTC) + timedelta(seconds=3600)} +def test_decode_session_id_missing_field(): + """新形式 payload に session_id が無ければ None (旧形式 trip_ids の JWT も含む)""" + payload = { + "trip_ids": [1, 2, 3], + "exp": datetime.now(UTC) + timedelta(seconds=3600), + } token = pyjwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") request = _make_request({SESSION_COOKIE_NAME: token}) - assert get_allowed_trip_ids(request) == set() + assert decode_session_id(request) is None -# ---- require_trip_access ---- +# ---- require_trip_access (DB 統合) ---- -def test_require_trip_access_allowed(): - token = _encode_session_jwt([1]) - request = _make_request({SESSION_COOKIE_NAME: token}) - assert require_trip_access(trip_id=1, request=request) == 1 +async def _seed_session_with_access( + db: AsyncSession, trip_id: int +) -> tuple[User, UserSession]: + user = User() + db.add(user) + await db.flush() + session = UserSession(id=generate_session_id(), user_id=user.id) + db.add(session) + db.add(UserTripAccess(user_id=user.id, trip_id=trip_id)) + await db.commit() + return user, session -def test_require_trip_access_forbidden(): - token = _encode_session_jwt([1]) - request = _make_request({SESSION_COOKIE_NAME: token}) +async def test_require_trip_access_allowed(db_session: AsyncSession): + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="allowed", detail=""), + url_id="require-allowed", + ) + _, session = await _seed_session_with_access(db_session, trip_id) + request = _make_request({SESSION_COOKIE_NAME: _encode_new_session_jwt(session.id)}) + assert ( + await require_trip_access(trip_id=trip_id, request=request, db=db_session) + == trip_id + ) + + +async def test_require_trip_access_forbidden_no_cookie(db_session: AsyncSession): + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="no-cookie", detail=""), + url_id="require-no-cookie", + ) + request = _make_request() with pytest.raises(Forbidden): - require_trip_access(trip_id=2, request=request) + await require_trip_access(trip_id=trip_id, request=request, db=db_session) -def test_require_trip_access_with_multiple_ids(): - """複数 trip_ids 含まれる場合、その中の任意の id でアクセス可能""" - token = _encode_session_jwt([1, 5, 10]) - request = _make_request({SESSION_COOKIE_NAME: token}) - assert require_trip_access(trip_id=5, request=request) == 5 +async def test_require_trip_access_forbidden_no_grant(db_session: AsyncSession): + """session はあるが user_trip_access に entry が無ければ 403""" + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="no-grant", detail=""), + url_id="require-no-grant", + ) + user = User() + db_session.add(user) + await db_session.flush() + session = UserSession(id=generate_session_id(), user_id=user.id) + db_session.add(session) + await db_session.commit() + + request = _make_request({SESSION_COOKIE_NAME: _encode_new_session_jwt(session.id)}) + with pytest.raises(Forbidden): + await require_trip_access(trip_id=trip_id, request=request, db=db_session) + + +# ---- _has_trip_access ---- + + +async def test_has_trip_access_none_session_id(db_session: AsyncSession): + assert await _has_trip_access(db_session, None, 1) is False + + +async def test_has_trip_access_unknown_session(db_session: AsyncSession): + assert await _has_trip_access(db_session, "nonexistent-session", 1) is False + + +# ---- grant_trip_access (idempotent) ---- + + +async def test_grant_trip_access_creates_session_and_grants(db_session: AsyncSession): + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="grant-new", detail=""), + url_id="grant-new", + ) + request = _make_request() + response = Response() + await grant_trip_access(request, response, trip_id, db_session) + await db_session.commit() + + # 匿名 user と session が作られ、trip へのアクセスが登録される + result = await db_session.execute( + select(UserTripAccess).where(UserTripAccess.trip_id == trip_id) + ) + accesses = result.scalars().all() + assert len(accesses) == 1 + assert accesses[0].trip_id == trip_id + + # Set-Cookie がレスポンスに含まれる + set_cookie = [h[1] for h in response.raw_headers if h[0].lower() == b"set-cookie"] + assert any(SESSION_COOKIE_NAME.encode() in h for h in set_cookie) + + +async def test_grant_trip_access_idempotent_on_concurrent_grants( + db_session: AsyncSession, +): + """同一 (user, trip) への複数付与が (user_id, trip_id) 複合 PK で idempotent 化されること""" + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="idempotent", detail=""), + url_id="idempotent", + ) + user, session = await _seed_session_with_access(db_session, trip_id) + request = _make_request({SESSION_COOKIE_NAME: _encode_new_session_jwt(session.id)}) + response = Response() + # 既にアクセス権があるところに追加 grant を走らせても行数は増えない + await grant_trip_access(request, response, trip_id, db_session) + await grant_trip_access(request, response, trip_id, db_session) + await db_session.commit() + + result = await db_session.execute( + select(UserTripAccess).where( + UserTripAccess.user_id == user.id, UserTripAccess.trip_id == trip_id + ) + ) + assert len(result.scalars().all()) == 1 From 3d8c83c20fe1ccd1f1cafc295b355251e48d35e3 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sun, 26 Jul 2026 12:12:16 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(#194):=20=E6=97=A7=20Cookie=20?= =?UTF-8?q?=E7=A7=BB=E8=A1=8C=E3=83=9F=E3=83=89=E3=83=AB=E3=82=A6=E3=82=A7?= =?UTF-8?q?=E3=82=A2=E3=82=92=E5=88=86=E9=9B=A2=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LegacyCookieMigrationMiddleware を app/middleware/ 下に配置 - 旧形式 (trip_ids 配列 payload) の JWT を検出したら匿名 user + session を 作成し、trip_ids を user_trip_access に ON CONFLICT DO NOTHING で移行、 新形式 JWT を Set-Cookie - 本体 auth.py には旧形式のロジックを持たせない (剥がしやすさ優先)。 リリース 2-3 ヶ月後、main.py の add_middleware 1 行とファイル一式を 削除するだけで撤去できる - middleware は db_connection.AsyncSessionLocal を属性 lookup で参照。 conftest でこの属性をテスト用 factory に差し替える - 統合テストを追加 (tests/test_legacy_cookie_migration.py) --- server/app/main.py | 7 + server/app/middleware/__init__.py | 0 .../app/middleware/legacy_cookie_migration.py | 141 +++++++++++++++ server/tests/conftest.py | 5 + server/tests/test_legacy_cookie_migration.py | 164 ++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 server/app/middleware/__init__.py create mode 100644 server/app/middleware/legacy_cookie_migration.py create mode 100644 server/tests/test_legacy_cookie_migration.py diff --git a/server/app/main.py b/server/app/main.py index 18df8baa..ae38cae2 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -19,6 +19,7 @@ validation_exception_handler, ) from app.firebase import init_firebase_admin +from app.middleware.legacy_cookie_migration import LegacyCookieMigrationMiddleware from app.observability import setup_observability from .routers import blocks, notification, notification_internal, pages, trips @@ -86,6 +87,12 @@ async def openapi_schema( ) +# issue #194 の移行期間限定のミドルウェア。旧形式 (trip_ids 配列) の Cookie を +# 検出したら透過的に新形式 (session_id) へ移行する。CORS より内側 (inner) に +# 置くことで preflight (Cookie 無) はスルーされる。 +# 削除タイミング: リリース 2-3 ヶ月後を目安に、この 1 行とファイル一式を削除する。 +app.add_middleware(LegacyCookieMigrationMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, diff --git a/server/app/middleware/__init__.py b/server/app/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app/middleware/legacy_cookie_migration.py b/server/app/middleware/legacy_cookie_migration.py new file mode 100644 index 00000000..f6094dff --- /dev/null +++ b/server/app/middleware/legacy_cookie_migration.py @@ -0,0 +1,141 @@ +"""旧形式セッション Cookie の透過的な移行ミドルウェア。 + +- 旧: JWT payload に `trip_ids` 配列を持つ (issue #194 以前の形式) +- 新: JWT payload に `session_id` を持つ + +旧 Cookie を検出したら、匿名 user と session を作成し、旧 trip_ids を +user_trip_access に ON CONFLICT DO NOTHING で移行する。同じリクエスト内で +downstream の認可判定が新 session を使えるよう、request の cookies も +差し替えた上でレスポンスに新形式 Cookie を Set-Cookie で付与する。 + +**削除時**: このファイルを削除し、`main.py` の add_middleware 登録行 (1 行) を +削除するだけで剥がせる。本体の auth.py には旧形式のロジックは含まれない。 +""" + +import jwt +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.types import ASGIApp + +from app import db_connection +from app.auth import ( + SESSION_COOKIE_NAME, + generate_session_id, + set_session_cookie, +) +from app.config import get_settings +from app.models import Trip, User, UserSession, UserTripAccess + + +def _extract_legacy_trip_ids(request: Request) -> list[int] | None: + """Cookie の JWT payload に trip_ids フィールドがあれば ints のリストを返す。 + + 旧形式でない (Cookie 無 / 新形式 / 不正) 場合は None を返す。 + """ + raw = request.cookies.get(SESSION_COOKIE_NAME) + if not raw: + return None + settings = get_settings() + try: + payload = jwt.decode(raw, settings.cookie_secret_key, algorithms=["HS256"]) + except (jwt.InvalidTokenError, jwt.ExpiredSignatureError): + return None + ids = payload.get("trip_ids") + if not isinstance(ids, list): + return None + return [tid for tid in ids if isinstance(tid, int)] + + +def _encode_new_session_jwt(session_id: str) -> str: + """新形式の JWT (payload に session_id のみ) を生成する。""" + from datetime import UTC, datetime, timedelta + + settings = get_settings() + payload = { + "session_id": session_id, + "exp": datetime.now(UTC) + timedelta(seconds=settings.cookie_max_age), + } + return jwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") + + +def _replace_session_cookie_in_scope(request: Request, new_jwt: str) -> None: + """request.scope の cookie header を書き換え、Request.cookies のキャッシュも無効化する。 + + downstream の Depends(get_db_session) / decode_session_id 等が新 JWT を + 見えるようにするための処置。BaseHTTPMiddleware は Request を再構築しないため、 + scope とキャッシュの両方を直接触る必要がある。 + """ + # scope の cookie header を差し替え + headers = [ + (name, value) + for name, value in request.scope["headers"] + if name.lower() != b"cookie" + ] + new_cookies = dict(request.cookies) + new_cookies[SESSION_COOKIE_NAME] = new_jwt + cookie_header = "; ".join(f"{k}={v}" for k, v in new_cookies.items()) + headers.append((b"cookie", cookie_header.encode())) + request.scope["headers"] = headers + + # Starlette Request.cookies は cached property のため、キャッシュを破棄する + if hasattr(request, "_cookies"): + del request._cookies + + +class LegacyCookieMigrationMiddleware(BaseHTTPMiddleware): + """旧 trip_ids JWT を新形式 (session_id) へ透過的に移行する。""" + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next): + legacy_trip_ids = _extract_legacy_trip_ids(request) + if legacy_trip_ids is None: + return await call_next(request) + + # 匿名 user + session を作成し、旧 trip_ids を user_trip_access に流し込む + new_session_id = generate_session_id() + # 属性 lookup で解決することで conftest の AsyncSessionLocal monkey-patch を効かせる + async with db_connection.AsyncSessionLocal() as db: + user = User() + db.add(user) + await db.flush() + db.add(UserSession(id=new_session_id, user_id=user.id)) + if legacy_trip_ids: + # 旅程削除後に旧 Cookie を持つクライアントが来ると、削除済み trip_id が + # 混入して FK 違反 → middleware 全体が 500 で永続 stuck する。 + # 実在する trip_id のみに絞ってから INSERT する。 + valid_trip_ids = ( + ( + await db.execute( + select(Trip.id).where(Trip.id.in_(legacy_trip_ids)) + ) + ) + .scalars() + .all() + ) + if valid_trip_ids: + await db.execute( + pg_insert(UserTripAccess) + .values( + [ + {"user_id": user.id, "trip_id": tid} + for tid in valid_trip_ids + ] + ) + .on_conflict_do_nothing( + index_elements=["user_id", "trip_id"] + ) + ) + await db.commit() + + # 同一リクエスト内で downstream から見えるよう request の Cookie を差し替える + new_jwt = _encode_new_session_jwt(new_session_id) + _replace_session_cookie_in_scope(request, new_jwt) + + response = await call_next(request) + # クライアントに新形式 Cookie を配って以降は移行が完了する + set_session_cookie(request, response, new_session_id) + return response diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 32a07654..c13113e6 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -13,6 +13,7 @@ ) from sqlalchemy.pool import NullPool +from app import db_connection from app.auth import SESSION_COOKIE_NAME, generate_session_id from app.config import get_settings from app.cruds import blocks as blocks_cruds @@ -58,6 +59,10 @@ async def override_get_db_session(): app.dependency_overrides[get_db_session] = override_get_db_session +# LegacyCookieMigrationMiddleware は Depends を経由せず db_connection.AsyncSessionLocal を +# 属性 lookup で参照している。テスト用エンジンで動かすため、ここで factory を差し替える。 +db_connection.AsyncSessionLocal = TestingAsyncSessionLocal + @pytest_asyncio.fixture(scope="function", autouse=True) async def setup_database(): diff --git a/server/tests/test_legacy_cookie_migration.py b/server/tests/test_legacy_cookie_migration.py new file mode 100644 index 00000000..2b8d42d6 --- /dev/null +++ b/server/tests/test_legacy_cookie_migration.py @@ -0,0 +1,164 @@ +"""LegacyCookieMigrationMiddleware の統合テスト。 + +旧形式 (trip_ids 配列) の Cookie を持つ既存ユーザーが、透過的に新形式 +(session_id) へ移行される挙動を検証する。移行期間終了時に本テストと +middleware 実装をセットで削除する想定。 +""" + +from datetime import UTC, datetime, timedelta + +import jwt as pyjwt +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import SESSION_COOKIE_NAME +from app.config import get_settings +from app.cruds import trips as trips_cruds +from app.models import User, UserSession, UserTripAccess +from app.schemas.trip import TripCreateIn + +settings = get_settings() + + +def _make_legacy_cookie(trip_ids: list[int]) -> str: + """旧形式 (trip_ids 配列) の JWT を生成する""" + payload = { + "trip_ids": sorted(trip_ids), + "exp": datetime.now(UTC) + timedelta(seconds=settings.cookie_max_age), + } + return pyjwt.encode(payload, settings.cookie_secret_key, algorithm="HS256") + + +async def test_legacy_cookie_migrates_and_grants_access( + client: AsyncClient, db_session: AsyncSession +): + """旧形式 Cookie を持つクライアントが認可を通過し、新形式 Cookie に切り替わる""" + trip_id_a = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="a", detail=""), + url_id="legacy-a", + ) + trip_id_b = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="b", detail=""), + url_id="legacy-b", + ) + + client.cookies.set(SESSION_COOKIE_NAME, _make_legacy_cookie([trip_id_a, trip_id_b])) + + # 認可が通る + r = await client.get(f"/trips/{trip_id_a}") + assert r.status_code == 200 + + # レスポンスに新形式 Set-Cookie が付与されている + # (client.cookies だと旧手動セット分と衝突するのでレスポンスから直接取る) + new_token = r.cookies.get(SESSION_COOKIE_NAME) + assert new_token is not None + payload = pyjwt.decode(new_token, settings.cookie_secret_key, algorithms=["HS256"]) + assert isinstance(payload.get("session_id"), str) + assert "trip_ids" not in payload + + # DB に匿名 user + session + user_trip_access が作られている + session_id = payload["session_id"] + session = await db_session.get(UserSession, session_id) + assert session is not None + + user = await db_session.get(User, session.user_id) + assert user is not None + assert user.firebase_uid is None # 匿名 user + + accesses = ( + ( + await db_session.execute( + select(UserTripAccess).where(UserTripAccess.user_id == user.id) + ) + ) + .scalars() + .all() + ) + assert {a.trip_id for a in accesses} == {trip_id_a, trip_id_b} + + +async def test_legacy_cookie_migration_skipped_for_new_cookie( + client: AsyncClient, db_session: AsyncSession +): + """新形式 Cookie は移行対象外 (そのままスルー)""" + response = await client.post("/trips", json={"title": "x", "detail": ""}) + trip_id = response.json()["id"] + original_token = client.cookies.get(SESSION_COOKIE_NAME) + + # 同じクライアントで GET しても Cookie は変わらない (移行が走らない) + r = await client.get(f"/trips/{trip_id}") + assert r.status_code == 200 + after_token = client.cookies.get(SESSION_COOKIE_NAME) + assert after_token == original_token + + +async def test_legacy_cookie_migration_no_cookie_is_noop( + client: AsyncClient, db_session: AsyncSession +): + """Cookie が無ければ移行は走らず、下位ハンドラの認可ロジックが 403 を返す""" + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="no-cookie", detail=""), + url_id="no-cookie", + ) + r = await client.get(f"/trips/{trip_id}") + assert r.status_code == 403 + # 新形式 Cookie も発行されていない + assert client.cookies.get(SESSION_COOKIE_NAME) is None + + +async def test_legacy_cookie_ignores_deleted_trip_ids( + client: AsyncClient, db_session: AsyncSession +): + """旧 trip_ids に削除済み ID が混じっていても FK 違反にならず、有効な trip_id のみ移行される""" + trip_id = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="alive", detail=""), + url_id="alive-only", + ) + # 実在する trip_id と実在しない trip_id を混ぜる + client.cookies.set(SESSION_COOKIE_NAME, _make_legacy_cookie([trip_id, 999999])) + + # 有効な trip にアクセスできる (500 ではなく 200) + r = await client.get(f"/trips/{trip_id}") + assert r.status_code == 200 + + # 新形式 Cookie が発行されている + new_token = r.cookies.get(SESSION_COOKIE_NAME) + assert new_token is not None + payload = pyjwt.decode(new_token, settings.cookie_secret_key, algorithms=["HS256"]) + session_id = payload["session_id"] + + # 有効な trip のみ user_trip_access に入っている + session = await db_session.get(UserSession, session_id) + assert session is not None + accesses = ( + ( + await db_session.execute( + select(UserTripAccess).where(UserTripAccess.user_id == session.user_id) + ) + ) + .scalars() + .all() + ) + assert {a.trip_id for a in accesses} == {trip_id} + + +async def test_legacy_cookie_with_empty_trip_ids_migrates_session_only( + client: AsyncClient, db_session: AsyncSession +): + """旧形式 Cookie で trip_ids が空 [] でも session だけは発行される""" + client.cookies.set(SESSION_COOKIE_NAME, _make_legacy_cookie([])) + + # 認可が必要な endpoint に叩くと 403 (access がないため) + r = await client.get("/trips/999999") + assert r.status_code == 403 + + # ただしレスポンスに新形式 Cookie は発行されている + new_token = r.cookies.get(SESSION_COOKIE_NAME) + assert new_token is not None + payload = pyjwt.decode(new_token, settings.cookie_secret_key, algorithms=["HS256"]) + assert isinstance(payload.get("session_id"), str) From 25c6d77dfa858dd9c61abcc38055cdb35577b890 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Thu, 30 Jul 2026 17:59:48 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(#194):=20Firebase=20Auth=20=E7=B5=B1?= =?UTF-8?q?=E5=90=88=E3=82=A8=E3=83=B3=E3=83=89=E3=83=9D=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=88=20(POST=20/auth/link)=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 匿名 user (Cookie 経由) を Firebase 認証済み user に紐付ける API を実装。 "バックアップ・追加機能" 位置づけで、編集の必須要件ではない。 - パターン 1: 同 firebase_uid の user が未存在 → 匿名 user の firebase_uid を 埋めて認証済に昇格 - パターン 2: 同 firebase_uid の user が既存 → 匿名 user の user_trip_access を ON CONFLICT DO NOTHING で統合し、session を振り替え、匿名 user を削除。 archived フラグは既存側を優先 (ON CONFLICT の性質) - パターン 3: 再認証は idempotent (何もしない) - verify_id_token 失敗 / session 無しは 401 - 5 つの統合テスト (verify_id_token は monkeypatch) --- server/app/main.py | 10 +- server/app/routers/auth.py | 145 +++++++++++++++++ server/tests/routers/test_auth_router.py | 191 +++++++++++++++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 server/app/routers/auth.py create mode 100644 server/tests/routers/test_auth_router.py diff --git a/server/app/main.py b/server/app/main.py index ae38cae2..ebe95d34 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -22,7 +22,14 @@ from app.middleware.legacy_cookie_migration import LegacyCookieMigrationMiddleware from app.observability import setup_observability -from .routers import blocks, notification, notification_internal, pages, trips +from .routers import ( + auth as auth_router, + blocks, + notification, + notification_internal, + pages, + trips, +) settings = get_settings() @@ -107,6 +114,7 @@ async def openapi_schema( app.include_router(trips.router) app.include_router(pages.router) app.include_router(blocks.router) +app.include_router(auth_router.router) app.include_router(notification.router) app.include_router(notification_internal.router) diff --git a/server/app/routers/auth.py b/server/app/routers/auth.py new file mode 100644 index 00000000..c32db109 --- /dev/null +++ b/server/app/routers/auth.py @@ -0,0 +1,145 @@ +"""Firebase Auth との統合エンドポイント。 + +現在の session (Cookie 経由の匿名 user) を Firebase 認証済み user に紐付ける。 +"バックアップ・追加機能" 位置づけで、認証は編集の必須要件ではない (issue #194)。 +""" + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from firebase_admin import auth as fb_auth +from firebase_admin.exceptions import FirebaseError +from pydantic import BaseModel +from sqlalchemy import select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import decode_session_id +from app.db_connection import get_db_session +from app.models import User, UserSession + +router = APIRouter(tags=["Auth"], prefix="/auth") + + +class LinkFirebaseIn(BaseModel): + id_token: str + + +class LinkFirebaseOut(BaseModel): + firebase_uid: str + + +def _verify_id_token(id_token: str) -> str: + """Firebase ID トークンを検証して firebase_uid を返す。失敗時は 401 を投げる。 + + FirebaseError 基底クラスで受けることで、CertificateFetchError などのネットワーク + 起因の一時例外も含めて 500 として露出しないようにする。 + """ + try: + decoded: dict[str, Any] = fb_auth.verify_id_token(id_token) + except (FirebaseError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Firebase ID token", + ) from e + uid = decoded.get("uid") + if not isinstance(uid, str): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="ID token payload has no uid", + ) + return uid + + +@router.post( + "/link", + summary="Firebase 認証情報を現在の session に紐付ける", + operation_id="auth-link-firebase", + response_model=LinkFirebaseOut, +) +async def link_firebase( + body: LinkFirebaseIn, + request: Request, + db: AsyncSession = Depends(get_db_session), +) -> LinkFirebaseOut: + """Firebase ID トークンを検証し、現在の session を認証済 user に統合する。 + + ケース分岐 (issue #194 の grill-me 議論参照): + - パターン 1 (初認証): 同 firebase_uid の user が DB に無い + → 匿名 user の firebase_uid を UPDATE で埋める (昇格) + - パターン 2 (別デバイスで先に認証済み): 同 firebase_uid の user が既に存在 + → 匿名 user の user_trip_access を INSERT ON CONFLICT DO NOTHING でマージし、 + session.user_id を振り替え、匿名 user を削除。archived フラグは既存側優先 + (ON CONFLICT DO NOTHING の性質による)。 + - パターン 3 (再認証): session が既に該当認証済 user に属している → 何もしない + """ + session_id = decode_session_id(request) + if session_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="No session" + ) + session = await db.get(UserSession, session_id) + if session is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session" + ) + + firebase_uid = _verify_id_token(body.id_token) + + current_user = await db.get(User, session.user_id) + if current_user is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Session references a missing user", + ) + + existing = ( + await db.execute(select(User).where(User.firebase_uid == firebase_uid)) + ).scalar_one_or_none() + + if existing is None: + # パターン 1: 匿名 user を認証済に昇格する。並行昇格 (別デバイスから同時に + # 初回リンク) で users.firebase_uid の unique 違反が起きうるため、IntegrityError + # を catch してパターン 2 (マージ) にフォールバックする。 + current_user.firebase_uid = firebase_uid + try: + await db.commit() + return LinkFirebaseOut(firebase_uid=firebase_uid) + except IntegrityError: + await db.rollback() + # rollback で ORM オブジェクトが expire するため再取得 + session = await db.get(UserSession, session_id) + if session is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Session lost" + ) from None + current_user = await db.get(User, session.user_id) + existing = ( + await db.execute(select(User).where(User.firebase_uid == firebase_uid)) + ).scalar_one_or_none() + if existing is None or current_user is None: + # unique 違反があったのに existing が見つからない = 想定外 + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to resolve concurrent auth link", + ) from None + # fall-through してパターン 2 のマージ処理へ + + # パターン 2 (別 user へマージ) または パターン 3 (existing.id == current_user.id で no-op) + if existing.id != current_user.id: + await db.execute( + text( + """ + INSERT INTO user_trip_access (user_id, trip_id, archived, granted_at) + SELECT :dst_user, trip_id, archived, granted_at + FROM user_trip_access WHERE user_id = :src_user + ON CONFLICT (user_id, trip_id) DO NOTHING + """ + ), + {"dst_user": existing.id, "src_user": current_user.id}, + ) + session.user_id = existing.id + await db.delete(current_user) + + await db.commit() + return LinkFirebaseOut(firebase_uid=firebase_uid) diff --git a/server/tests/routers/test_auth_router.py b/server/tests/routers/test_auth_router.py new file mode 100644 index 00000000..21aa327e --- /dev/null +++ b/server/tests/routers/test_auth_router.py @@ -0,0 +1,191 @@ +"""Firebase Auth 統合エンドポイント (POST /auth/link) のテスト。 + +Firebase Admin SDK の verify_id_token は monkeypatch で差し替える。 +router は別 AsyncSession で commit するため、test 側では identity map を避けて +raw SQL で結果を検証する。 +""" + +from typing import Any + +import pytest +from httpx import AsyncClient +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import SESSION_COOKIE_NAME, generate_session_id +from app.models import User, UserSession +from tests.conftest import _make_session_cookie_value + + +def _mock_verify_id_token(monkeypatch: pytest.MonkeyPatch, uid: str) -> None: + """verify_id_token を uid を返す関数に差し替える""" + + def _fake_verify(id_token: str, *args: Any, **kwargs: Any) -> dict[str, Any]: + return {"uid": uid} + + monkeypatch.setattr("app.routers.auth.fb_auth.verify_id_token", _fake_verify) + + +def _mock_verify_id_token_raises( + monkeypatch: pytest.MonkeyPatch, exc: Exception +) -> None: + def _raise(id_token: str, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise exc + + monkeypatch.setattr("app.routers.auth.fb_auth.verify_id_token", _raise) + + +async def _make_anon_session(db: AsyncSession) -> UserSession: + """匿名 user + session を作成して session を返す""" + user = User() + db.add(user) + await db.flush() + session = UserSession(id=generate_session_id(), user_id=user.id) + db.add(session) + await db.commit() + return session + + +async def test_link_promotes_anonymous_user( + client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +): + """パターン 1: 同 firebase_uid の user がまだ無ければ、匿名 user を認証済に昇格""" + _mock_verify_id_token(monkeypatch, uid="firebase-uid-new") + session = await _make_anon_session(db_session) + client.cookies.set(SESSION_COOKIE_NAME, _make_session_cookie_value(session.id)) + + r = await client.post("/auth/link", json={"id_token": "any"}) + assert r.status_code == 200 + assert r.json() == {"firebase_uid": "firebase-uid-new"} + + user = await db_session.get(User, session.user_id) + assert user is not None + assert user.firebase_uid == "firebase-uid-new" + + +async def test_link_merges_into_existing_user( + client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +): + """パターン 2: 同 firebase_uid の user が既にあれば、匿名 user の trip_access をマージし session を振り替え""" + _mock_verify_id_token(monkeypatch, uid="firebase-uid-existing") + + # 別デバイスで先に認証済み: existing user は trip 100 を archived=true で持つ + existing = User(firebase_uid="firebase-uid-existing") + db_session.add(existing) + await db_session.flush() + + # マージ対象の trip を作成 (users を先に作った後で trip を FK 有効な状態で追加) + from app.cruds import trips as trips_cruds + from app.schemas.trip import TripCreateIn + + trip_shared = await trips_cruds.create_trip( + db=db_session, trip=TripCreateIn(title="shared", detail=""), url_id="shared" + ) + trip_anon_only = await trips_cruds.create_trip( + db=db_session, + trip=TripCreateIn(title="anon-only", detail=""), + url_id="anon-only", + ) + + # 事前準備: raw SQL で archived=true を含む access を仕込む + # (test 側 ORM で add すると router 側とキャッシュが噛み合わない) + await db_session.execute( + text( + "INSERT INTO user_trip_access (user_id, trip_id, archived) " + "VALUES (:uid, :tid, TRUE)" + ), + {"uid": existing.id, "tid": trip_shared}, + ) + # 匿名 user (現在の session): shared (archived=false) + anon-only + session = await _make_anon_session(db_session) + anon_user_id = session.user_id + await db_session.execute( + text( + "INSERT INTO user_trip_access (user_id, trip_id, archived) " + "VALUES (:uid, :tid, FALSE), (:uid, :tid2, FALSE)" + ), + {"uid": anon_user_id, "tid": trip_shared, "tid2": trip_anon_only}, + ) + await db_session.commit() + + client.cookies.set(SESSION_COOKIE_NAME, _make_session_cookie_value(session.id)) + + r = await client.post("/auth/link", json={"id_token": "any"}) + assert r.status_code == 200 + + # router は別 AsyncSession で commit するので、identity map を避けて raw SQL で検証する + session_user_id = ( + await db_session.execute( + text("SELECT user_id FROM sessions WHERE id = :sid"), + {"sid": session.id}, + ) + ).scalar() + assert session_user_id == existing.id + + # 匿名 user は削除されている + anon_count = ( + await db_session.execute( + text("SELECT COUNT(*) FROM users WHERE id = :uid"), + {"uid": anon_user_id}, + ) + ).scalar() + assert anon_count == 0 + + # existing の trip_access: 両 trip 分が入っており、shared の archived は既存側 (true) が保持される + rows = ( + await db_session.execute( + text( + "SELECT trip_id, archived FROM user_trip_access " + "WHERE user_id = :uid ORDER BY trip_id" + ), + {"uid": existing.id}, + ) + ).all() + by_trip = {row.trip_id: row.archived for row in rows} + assert by_trip == {trip_shared: True, trip_anon_only: False} + + +async def test_link_is_idempotent_on_reauth( + client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +): + """パターン 3: session が既に該当認証済 user のものなら (再認証) 何もしない""" + _mock_verify_id_token(monkeypatch, uid="firebase-uid-reauth") + + user = User(firebase_uid="firebase-uid-reauth") + db_session.add(user) + await db_session.flush() + session = UserSession(id=generate_session_id(), user_id=user.id) + db_session.add(session) + await db_session.commit() + + client.cookies.set(SESSION_COOKIE_NAME, _make_session_cookie_value(session.id)) + + r = await client.post("/auth/link", json={"id_token": "any"}) + assert r.status_code == 200 + + # 何も変わっていないことを確認: user 一意、session の user_id 変わらず + await db_session.refresh(session) + assert session.user_id == user.id + users = (await db_session.execute(select(User))).scalars().all() + assert len(users) == 1 # 増減なし + + +async def test_link_without_session_returns_401( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +): + _mock_verify_id_token(monkeypatch, uid="firebase-uid-x") + r = await client.post("/auth/link", json={"id_token": "any"}) + assert r.status_code == 401 + + +async def test_link_invalid_id_token_returns_401( + client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +): + from firebase_admin.auth import InvalidIdTokenError + + _mock_verify_id_token_raises(monkeypatch, InvalidIdTokenError("bad")) + session = await _make_anon_session(db_session) + client.cookies.set(SESSION_COOKIE_NAME, _make_session_cookie_value(session.id)) + + r = await client.post("/auth/link", json={"id_token": "invalid"}) + assert r.status_code == 401 From 1c25f58c734e9be5c827880de7b67b0f4bb29792 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Thu, 30 Jul 2026 18:36:12 +0000 Subject: [PATCH 4/6] =?UTF-8?q?perf(#194):=20page/block=20=E8=AA=8D?= =?UTF-8?q?=E5=8F=AF=E3=82=92=20JOIN=201=20=E7=99=BA=E5=8C=96=20(2=20?= =?UTF-8?q?=E3=82=AF=E3=82=A8=E3=83=AA=20=E2=86=92=201=20=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=AA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require_page_access / require_block_access は "trip_id 解決 → 認可判定" を 別々の SELECT で 2 回投げていたが、EXISTS を絡めた JOIN 1 発に統合。 ページ・ブロック CRUD (ドラッグ&ドロップ等) の DB 往復数を認可分だけ削減する。 --- server/app/auth.py | 52 +++++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/server/app/auth.py b/server/app/auth.py index 81f46915..9fc43e56 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -193,15 +193,28 @@ async def require_page_access( page_id: int, request: Request, ) -> int: - """page_id から trip_id を解決し、アクセス権を検証する。""" - result = await db.execute(select(Page.trip_id).where(Page.id == page_id)) - trip_id = result.scalar_one_or_none() - if trip_id is None: - raise NotFound(message="Page not found") + """page_id から trip_id を解決し、アクセス権を検証する。 - if not await _has_trip_access(db, decode_session_id(request), trip_id): + trip_id 解決と権限判定を JOIN 1 発で行う (ページ・ブロック CRUD は最頻出 + エンドポイントのため、認可の DB 往復数を最小化する)。 + """ + session_id = decode_session_id(request) + stmt = select( + Page.trip_id, + exists() + .where( + UserSession.id == session_id, + UserTripAccess.user_id == UserSession.user_id, + UserTripAccess.trip_id == Page.trip_id, + ) + .label("has_access"), + ).where(Page.id == page_id) + row = (await db.execute(stmt)).one_or_none() + if row is None: + raise NotFound(message="Page not found") + if not row.has_access: raise Forbidden() - return trip_id + return row.trip_id async def require_block_access( @@ -209,16 +222,25 @@ async def require_block_access( block_id: int, request: Request, ) -> int: - """block_id から trip_id を解決し、アクセス権を検証する。""" - result = await db.execute( - select(Page.trip_id) + """block_id から trip_id を解決し、アクセス権を検証する。JOIN 1 発。""" + session_id = decode_session_id(request) + stmt = ( + select( + Page.trip_id, + exists() + .where( + UserSession.id == session_id, + UserTripAccess.user_id == UserSession.user_id, + UserTripAccess.trip_id == Page.trip_id, + ) + .label("has_access"), + ) .join(Block, Block.page_id == Page.id) .where(Block.id == block_id) ) - trip_id = result.scalar_one_or_none() - if trip_id is None: + row = (await db.execute(stmt)).one_or_none() + if row is None: raise NotFound(message="Block not found") - - if not await _has_trip_access(db, decode_session_id(request), trip_id): + if not row.has_access: raise Forbidden() - return trip_id + return row.trip_id From be6c0d8333ac08ecedcd714857f8127478ae818b Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Thu, 30 Jul 2026 19:15:52 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(#194):=20=E8=A6=81=E4=BB=B6=E5=AE=9A?= =?UTF-8?q?=E7=BE=A9=E3=82=92=E8=AA=8D=E5=8F=AF=E3=81=AE=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=AD=E3=83=BC=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E5=8C=96=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=81=A6=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 認証・共有機能: 「編集は Firebase Auth 必須」を撤回、URL を知っている 人は誰でも閲覧・編集可能に更新。メール認証はバックアップ・追加機能として 明記 - 技術スタック: 実態に合わせて MySQL → PostgreSQL, Railway → Cloud Run - iOS Safari ITP による Cookie 7 日パージを技術制約に記載 - Phase 2 に FCM 通知、Phase 3 に一覧アーカイブを追加 - URL 設計・セキュリティ要件・データ構造・開発フェーズ・コスト管理も 現状の実装に整合 --- docs/requirements.md | 74 +++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index 6eb3d625..7284b6f1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -50,12 +50,17 @@ #### 認証・共有機能 -- **閲覧**: ログイン不要、tripId(ハッシュ化)を知っていれば誰でもアクセス可能 -- **編集**: Firebase Authenticationによるメール認証(パスワードレス) -- **セッション**: 長期間有効 -- **権限管理**: - - 旅程作成者: 削除権限、AI機能利用権限 - - 編集参加者: 閲覧画面から「一緒に編集する」ボタンで編集モードに移行 +- **閲覧・編集ともログイン不要**: tripId(ハッシュ化)を知っている人は誰でも閲覧・編集可能 + - グループ利用(家族/友人)を前提とし、編集の絞り込みは行わない + - URL 共有相手には同等の編集権限を与える設計 +- **認可の仕組み**: HttpOnly Cookie に不透明トークン (session_id) を格納し、DB の `user_trip_access` テーブルで trip 単位のアクセス権を管理する(旧 `trip_ids` 配列 JWT からセッションキー方式へ移行、issue #194) +- **セッション**: 長期間有効 (Cookie Max-Age = 30 日、アクセス毎に延長) +- **メール認証** (オプション、Firebase Authentication のパスワードレス方式): + - **バックアップ・追加機能** として位置付ける(編集の必須要件ではない) + - 有効化することで以下ができるようになる: + - デバイス間での旅程一覧同期 + - Cookie 消失 (ブラウザデータクリア、機種変更、iOS Safari の ITP 7 日パージ) 時のリカバリ + - 認証時は同一 `firebase_uid` の user に session を紐付けて統合する ### Phase 2 @@ -73,10 +78,17 @@ - **競合解決**: 同時編集時の後勝ちルール - **接続状態管理**: 編集参加者の状態可視化 +#### プッシュ通知 + +- **FCM (Firebase Cloud Messaging)** による端末単位の通知 +- 出発時刻の直前リマインダー +- 端末 × Trip 単位で購読を管理 (`device_subscriptions` テーブル) + ### Phase 3 #### 追加管理機能 +- **旅程一覧のアーカイブ**: `user_trip_access.archived` フラグでの一覧非表示 - **外部URL管理**: Walica等の割り勘サービスURL管理 - **旅行メタ情報管理**: 旅行名、期間、参加者、テーマ等 - **予約情報統合管理**: 各施設の予約情報の一元管理 @@ -96,19 +108,22 @@ - **フレームワーク**: React + TypeScript + Vite - **UI/UX**: Shadcn/ui + Tailwind CSS - **ドラッグ&ドロップ**: @dnd-kit -- **認証**: Firebase Authentication +- **認証 (オプション)**: Firebase Authentication (パスワードレス、バックアップ用) #### バックエンド - **フレームワーク**: Python + FastAPI -- **データベース**: MySQL +- **データベース**: PostgreSQL +- **認可**: セッションキー方式 (HttpOnly Cookie の JWT に session_id、DB で `user_trip_access` を参照) +- **認証 (オプション)**: Firebase Authentication (Firebase Admin SDK による ID トークン検証) - **リアルタイム通信**: WebSocket(Socket.io検討) +- **プッシュ通知**: Firebase Cloud Messaging (FCM) #### インフラ - **フロントエンド**: Firebase Hosting -- **バックエンド**: Railway -- **データベース**: Railway MySQL +- **バックエンド**: Google Cloud Run +- **データベース**: PostgreSQL (Cloud SQL / Neon 等) #### 外部API @@ -121,17 +136,20 @@ - リアルタイム同期レスポンス: 1秒以内 - 画面遷移: 2秒以内 - モバイル対応 +- 認可判定は 1 リクエストあたり 1 クエリで完結する (page/block CRUD は JOIN 1 発) #### セキュリティ要件 -- tripIdのハッシュ化によるURL推測防止 -- Firebase Authenticationによる認証 +- tripIdのハッシュ化によるURL推測防止 (認可の主軸) +- HttpOnly Cookie による session_id 管理 (JavaScript からのアクセス防止) - HTTPS通信 +- メール認証 (オプション) は Firebase Authentication のパスワードレス方式 +- 並列アクセス時のアクセス権付与は `(user_id, trip_id)` 複合 PK + `ON CONFLICT DO NOTHING` で idempotent 化 #### 可用性要件 - サービス稼働率: 99%以上 -- コールドスタート回避(Railway使用) +- コールドスタート回避(Cloud Run 最小インスタンス構成) ## URL設計 @@ -143,15 +161,24 @@ ### アクセスパターン -1. **閲覧モード**: 誰でもアクセス可能 -2. **編集モード**: 「一緒に編集する」ボタン → Firebase認証 → 編集モード切り替え +- URL (`/trip/[secureHashId]`) を知っている人は誰でも閲覧・編集可能 +- 初回アクセス時に匿名 session と共に `user_trip_access` へアクセス権が付与される +- メール認証 (オプション) を経由すると、複数デバイスで同じ旅程一覧を共有できる ### tripId仕様 - ランダムハッシュ値(推測困難) - 例: nanoid(16) または crypto.randomUUID() + timestamp -## データ構造(未定) +## データ構造 + +論理設計の全体像は [db_schema.md](./db_schema.md) を参照。 + +認可関連の主なテーブル: + +- `users` — 認可の主体。Cookie 発行時に匿名 user (`firebase_uid IS NULL`) を自動作成し、Firebase 認証時に `firebase_uid` を埋めて昇格させる +- `sessions` — Cookie に載る `session_id` と `user_id` の紐付け +- `user_trip_access` — `(user_id, trip_id)` 複合 PK の中間表。並列付与の race を `ON CONFLICT DO NOTHING` で idempotent に解消する ## 開発フェーズ @@ -159,17 +186,19 @@ - 基本的なブロックUI実装 - ドラッグ&ドロップ機能 -- Firebase認証実装 -- 閲覧/編集権限機能 +- セッションキー方式による認可 (issue #194) +- メール認証 (Firebase Auth) をオプション機能として実装 ### Phase 2: 中核機能 - Google Maps API連携 -- リアルタイム同期機能 +- リアルタイム同期機能 (WebSocket) +- プッシュ通知 (FCM) - モバイル対応 ### Phase 3: 拡張機能 +- 旅程一覧のアーカイブ - 外部URL管理画面 - AI機能実装 - 各種最適化 @@ -179,7 +208,8 @@ ### コスト管理 - Firebase Hosting: 無料枠内運用 -- Railway: 月額3-5ドル程度 +- Google Cloud Run: 従量課金 (通常運用で月数ドル程度) +- Firebase Authentication: 無料枠内 (パスワードレスメール認証は月 10K auth まで無料) - Google Maps API: 無料枠内、制限機能実装 ### 監視・ログ @@ -194,6 +224,7 @@ - Google Maps API無料枠内での運用 - 個人開発のためミニマム構成 +- iOS Safari の ITP により、iOS からのアクセスでは HttpOnly Cookie が 7 日程度でパージされうる (Cloud Run + Firebase Hosting 構成の IP prefix 不一致による)。メール認証がオプションで用意されているのはこのリカバリ手段でもある ### ビジネス制約 @@ -203,4 +234,5 @@ ### 設計制約 - リアルタイム同期における競合解決は後勝ちルール -- 認証はメール認証のみ(SNS認証は未対応) +- 認可は URL の秘匿性 + session_id で担保する (認証は必須ではない) +- メール認証 (オプション) はパスワードレス方式のみ (SNS 認証は未対応) From 80241fee28860de94dd20d6620d60175ad54dbe7 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sat, 1 Aug 2026 01:48:02 +0000 Subject: [PATCH 6/6] =?UTF-8?q?feat(#194):=20Firebase=20Auth=20=E3=83=95?= =?UTF-8?q?=E3=83=AD=E3=83=B3=E3=83=88=E7=B5=B1=E5=90=88=E3=81=A8=20"?= =?UTF-8?q?=E5=90=8C=E6=9C=9F"=20=E3=82=BB=E3=82=AF=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/firebase.ts に getFirebaseAuth を追加 (FCM 用の Firebase App を流用) - atoms/auth.ts: authUserAtom (User | null | undefined、初期化前/未認証/認証済) - hooks/useAuth.ts: useAuthStateSync / sendMagicLink / completeMagicLinkSignInIfPresent / signOut - App 直下で useAuthStateSync を呼び、マウント時にマジックリンクを検出したら signInWithEmailLink → POST /auth/link で session と firebase_uid を紐付け - SyncSignInDialog: メールアドレス入力 → sendSignInLinkToEmail (email は localStorage に保存、戻り時に signInWithEmailLink で使用) - SyncSection: 未認証時のみ表示される旅程一覧上のバナー的セクション (ポジティブ訴求のみ、iOS Cookie 制約は明示しない) - HomePage の「最近見た旅程一覧」直下に配置 編集は従来通り URL 知ってる人 = 誰でも可。メール認証は "バックアップ・ 追加機能" として位置付ける。実機検証項目は PR に記載。 --- frontend/src/App.tsx | 2 + frontend/src/atoms/auth.ts | 14 +++ frontend/src/components/SyncSection.tsx | 43 ++++++++ frontend/src/dialogs/SyncSignInDialog.tsx | 118 ++++++++++++++++++++++ frontend/src/hooks/useAuth.ts | 80 +++++++++++++++ frontend/src/lib/firebase.ts | 9 ++ frontend/src/pages/HomePage.tsx | 4 + 7 files changed, 270 insertions(+) create mode 100644 frontend/src/atoms/auth.ts create mode 100644 frontend/src/components/SyncSection.tsx create mode 100644 frontend/src/dialogs/SyncSignInDialog.tsx create mode 100644 frontend/src/hooks/useAuth.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b93d72e7..ef398f4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'; import { isOfflineReadAtom } from './atoms/network'; import { NoIndex } from './components/NoIndex'; import { Title } from './components/Title'; +import { useAuthStateSync } from './hooks/useAuth'; import { useForegroundNotificationToast } from './hooks/useForegroundNotificationToast'; import { useNetworkToast } from './hooks/useNetworkToast'; import { usePageTracking } from './hooks/usePageTracking'; @@ -19,6 +20,7 @@ const App = () => { useNetworkToast(); usePageTracking(); useForegroundNotificationToast(); + useAuthStateSync(); return ( <> diff --git a/frontend/src/atoms/auth.ts b/frontend/src/atoms/auth.ts new file mode 100644 index 00000000..64e047fd --- /dev/null +++ b/frontend/src/atoms/auth.ts @@ -0,0 +1,14 @@ +import type { User } from 'firebase/auth'; +import { atom } from 'jotai'; + +/** + * Firebase Auth の現在のユーザー。 + * + * - `undefined`: 初期化前 (onAuthStateChanged がまだ呼ばれていない) + * - `null`: 未認証 (匿名 session でアプリを利用中) + * - `User`: メール認証済み (バックアップ・同期機能が有効) + */ +export const authUserAtom = atom(undefined); + +/** メール認証済みかどうか。初期化前は false 扱い。 */ +export const isAuthenticatedAtom = atom(get => get(authUserAtom) != null); diff --git a/frontend/src/components/SyncSection.tsx b/frontend/src/components/SyncSection.tsx new file mode 100644 index 00000000..d51a6cca --- /dev/null +++ b/frontend/src/components/SyncSection.tsx @@ -0,0 +1,43 @@ +import { useAtomValue } from 'jotai'; +import { MailIcon } from 'lucide-react'; +import { useState } from 'react'; +import { authUserAtom } from '@/atoms/auth'; +import { Button } from '@/components/ui/button'; +import { SyncSignInDialog } from '@/dialogs/SyncSignInDialog'; + +/** + * 旅程一覧ページに常時配置される "同期" セクション。 + * + * - 未認証時のみ表示 (認証済みなら非表示) + * - CTA は「同期する」(バックアップ・追加機能の位置づけ、"登録" 感を出さない) + * - iOS Safari の Cookie 7 日パージ制約は明示しない (ポジティブ訴求のみ) + */ +export const SyncSection = () => { + const authUser = useAtomValue(authUserAtom); + const [dialogOpen, setDialogOpen] = useState(false); + + // 認証済み・初期化前は表示しない + if (authUser !== null) return null; + + return ( + <> +
+
+
+ +
+

デバイス間で旅程を同期

+

+ メール認証で複数デバイスから同じ一覧にアクセス、機種変更でも消えません。 +

+
+
+ +
+
+ + + ); +}; diff --git a/frontend/src/dialogs/SyncSignInDialog.tsx b/frontend/src/dialogs/SyncSignInDialog.tsx new file mode 100644 index 00000000..3dc9141a --- /dev/null +++ b/frontend/src/dialogs/SyncSignInDialog.tsx @@ -0,0 +1,118 @@ +import { type FormEvent, useEffect, useId, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { sendMagicLink } from '@/hooks/useAuth'; + +interface SyncSignInDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type Status = 'idle' | 'sending' | 'sent' | 'error'; + +/** + * メールアドレスを入力してマジックリンクを送信するダイアログ。 + * "同期" セクションから開かれる。認証が完了するのは、届いたメールのリンクを + * 同じデバイスで開いた時 (useAuthStateSync 内の completeMagicLinkSignIn) 。 + */ +export const SyncSignInDialog = ({ open, onOpenChange }: SyncSignInDialogProps) => { + const emailId = useId(); + const [email, setEmail] = useState(''); + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (open) { + setEmail(''); + setStatus('idle'); + setErrorMessage(null); + } + }, [open]); + + const handleSubmit = async (e: FormEvent): Promise => { + e.preventDefault(); + if (email.length === 0) return; + setStatus('sending'); + setErrorMessage(null); + try { + await sendMagicLink(email); + setStatus('sent'); + } catch (err) { + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : '送信に失敗しました。時間をおいて再度お試しください。'); + } + }; + + const isSending = status === 'sending'; + + return ( + { + if (isSending) return; + onOpenChange(next); + }} + > + { + if (isSending) e.preventDefault(); + }} + onEscapeKeyDown={e => { + if (isSending) e.preventDefault(); + }} + > + + メールで同期を有効にする + + + {status === 'sent' ? ( +

+ {email} にリンクを送信しました。 +
+ メールを開き、同じデバイスでリンクをタップしてください。 +

+ ) : ( +
+

+ 届いたメールのリンクを同じデバイスで開くと、同期が有効になります。 +

+
+ + setEmail(e.target.value)} + placeholder='you@example.com' + required + type='email' + value={email} + /> +
+ {errorMessage != null &&

{errorMessage}

} +
+ )} +
+ + {status === 'sent' ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +}; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..9f10a1d1 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,80 @@ +import { + signOut as firebaseSignOut, + isSignInWithEmailLink, + onAuthStateChanged, + sendSignInLinkToEmail, + signInWithEmailLink, +} from 'firebase/auth'; +import { useSetAtom } from 'jotai'; +import { useEffect } from 'react'; +import { authUserAtom } from '@/atoms/auth'; +import { apiClient } from '@/lib/apiClient'; +import { getFirebaseAuth } from '@/lib/firebase'; + +/** + * マジックリンクの遷移先。同じデバイスの同じブラウザで戻ってくることを想定し、 + * SPA のトップにリダイレクトする (クエリでリンク検証情報が付いてくる)。 + */ +const getActionCodeSettings = () => ({ + url: `${window.location.origin}/`, + handleCodeInApp: true, +}); + +const PENDING_EMAIL_STORAGE_KEY = 'auth:pendingEmail'; + +/** + * Firebase Auth の状態を Jotai atom に反映する。App 直下で 1 度だけ呼び出す。 + * マウント時にマジックリンクを検出したらサインインを自動完了する。 + */ +export const useAuthStateSync = (): void => { + const setAuthUser = useSetAtom(authUserAtom); + + useEffect(() => { + const auth = getFirebaseAuth(); + const unsubscribe = onAuthStateChanged(auth, user => { + setAuthUser(user); + }); + return () => unsubscribe(); + }, [setAuthUser]); + + useEffect(() => { + // fire-and-forget: マジックリンクでの復帰でなければ即座に何もしない + void completeMagicLinkSignInIfPresent(); + }, []); +}; + +/** + * 現在の URL がマジックリンクなら Firebase の signIn を完了し、 + * 続けてサーバーの POST /auth/link で session と firebase_uid を紐付ける。 + */ +export const completeMagicLinkSignInIfPresent = async (): Promise => { + const auth = getFirebaseAuth(); + if (!isSignInWithEmailLink(auth, window.location.href)) return; + + const email = window.localStorage.getItem(PENDING_EMAIL_STORAGE_KEY); + if (email == null) { + // 別デバイスでリンクを開いた等でメールアドレスが取れないケース。 + // 将来 UI で再入力させる。今回は console.warn に留める + console.warn('Magic link opened without pending email'); + return; + } + + const cred = await signInWithEmailLink(auth, email, window.location.href); + const idToken = await cred.user.getIdToken(); + await apiClient.post('/auth/link', { id_token: idToken }); + + window.localStorage.removeItem(PENDING_EMAIL_STORAGE_KEY); + // URL のクエリを消して、リロード時に再度マジックリンクとして扱われるのを防ぐ + window.history.replaceState({}, '', window.location.pathname); +}; + +/** マジックリンクを送信する。email は localStorage に保存しておき、戻ってきた時に使う。 */ +export const sendMagicLink = async (email: string): Promise => { + window.localStorage.setItem(PENDING_EMAIL_STORAGE_KEY, email); + await sendSignInLinkToEmail(getFirebaseAuth(), email, getActionCodeSettings()); +}; + +/** サインアウトする (Firebase 側のみ、サーバー session は残る)。 */ +export const signOut = async (): Promise => { + await firebaseSignOut(getFirebaseAuth()); +}; diff --git a/frontend/src/lib/firebase.ts b/frontend/src/lib/firebase.ts index 20fbbf63..ad656cb0 100644 --- a/frontend/src/lib/firebase.ts +++ b/frontend/src/lib/firebase.ts @@ -1,6 +1,8 @@ import { type FirebaseApp, initializeApp } from 'firebase/app'; +import { type Auth, getAuth } from 'firebase/auth'; let cachedApp: FirebaseApp | null = null; +let cachedAuth: Auth | null = null; export const getFirebaseApp = (): FirebaseApp => { if (cachedApp !== null) return cachedApp; @@ -16,3 +18,10 @@ export const getFirebaseApp = (): FirebaseApp => { return cachedApp; }; + +/** Firebase Authentication インスタンスを取得する (issue #194 のバックアップ用メール認証で利用)。 */ +export const getFirebaseAuth = (): Auth => { + if (cachedAuth !== null) return cachedAuth; + cachedAuth = getAuth(getFirebaseApp()); + return cachedAuth; +}; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 4a5ecfe7..514c5615 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { isOfflineReadAtom } from '@/atoms/network'; import { Header } from '@/components/Header'; import { PwaInstallBanner } from '@/components/PwaInstallBanner'; +import { SyncSection } from '@/components/SyncSection'; import { Button } from '@/components/ui/button'; import { AddTripDialog } from '@/dialogs/AddTripDialog'; import { useVisitedTrips } from '@/hooks/useVisitedTrips'; @@ -37,6 +38,9 @@ const HomePage = () => { + {/* 未認証時のみ表示される "同期" セクション (issue #194) */} + + {!(isLoading || hasTrips) && (