diff --git a/.env.example b/.env.example index 466383e..f6cb837 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,41 @@ # Application settings -APP_ENV=development -API_TITLE=AIE-DXproject Backend -API_DEBUG=true +APP_ENV="development" +API_TITLE="AIE-DXproject Backend" +API_DEBUG="true" # Database settings DATABASE_URL="postgresql://user:password@host:port/dbname" # AWS credentials (optional) -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# AWS_SESSION_TOKEN= -# AWS_REGION=ap-northeast-1 +# AWS_ACCESS_KEY_ID="your-access-key-id" +# AWS_SECRET_ACCESS_KEY="your-secret-access-key" +# AWS_SESSION_TOKEN="your-session-token" +# AWS_REGION="ap-northeast-1" # Upload storage (defaults to local filesystem) -UPLOAD_BACKEND=local +UPLOAD_BACKEND="local" UPLOAD_LOCAL_DIRECTORY="./var/uploads" # UPLOAD_S3_BUCKET="my-upload-bucket" # UPLOAD_BASE_PREFIX="uploads" # LLM integration -LLM_PROVIDER=mock +LLM_PROVIDER="mock" # Uncomment and configure when using an external LLM # LLM_API_BASE="https://api.openai.com/v1/chat/completions" # LLM_MODEL="gpt-4o-mini" # LLM_API_KEY="your-api-key" -# LLM_TIMEOUT_SECONDS=15 +# LLM_TIMEOUT_SECONDS="15" # LLM_REQUEST_TEMPLATE="You are an assistant ..." # Celery background worker CELERY_BROKER_URL="redis://redis:6379/0" CELERY_RESULT_BACKEND="redis://redis:6379/1" -# CELERY_TASK_ALWAYS_EAGER=true +# CELERY_TASK_ALWAYS_EAGER="true" + +# Cognito authentication +COGNITO_DOMAIN="your-domain.auth.ap-northeast-1.amazoncognito.com" +COGNITO_CLIENT_ID="your-client-id" +COGNITO_LOGOUT_REDIRECT_URI="https://your-app.example.com/" + +# Frontend URL +FRONTEND_URL="http://localhost:5173" diff --git a/app/api/auth.py b/app/api/auth.py index 4d9111e..b7a9091 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,8 +1,22 @@ -from fastapi import APIRouter, Request +from urllib.parse import urlencode + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse from pydantic import BaseModel +from app.core.settings import get_settings + + router = APIRouter() +ALB_AUTH_COOKIE_NAMES = [ + "AWSELBAuthSessionCookie", + "AWSELBAuthSessionCookie-0", + "AWSELBAuthSessionCookie-1", + "AWSELBAuthSessionCookie-2", + "AWSELBAuthSessionCookie-3", +] + class UserInfoResponse(BaseModel): sub: str | None @@ -25,9 +39,6 @@ def get_current_user(request: Request): ) -from app.core.settings import get_settings -from fastapi.responses import RedirectResponse - @router.get("/login", summary="Login Redirect") def login_redirect(): """ @@ -38,3 +49,41 @@ def login_redirect(): # 末尾のスラッシュ調整などは必要に応じて行うが、基本は設定値を信頼 target_url = settings.frontend_url return RedirectResponse(url=target_url, status_code=302) + + +@router.get("/logout", tags=["Auth"]) +def logout(): + settings = get_settings() + + if not all([ + settings.cognito.domain, + settings.cognito.client_id, + settings.cognito.logout_redirect_uri, + ]): + raise HTTPException( + status_code=500, detail="Cognito settings not configured" + ) + + params = { + "client_id": settings.cognito.client_id, + "logout_uri": settings.cognito.logout_redirect_uri, + } + cognito_logout_url = ( + f"https://{settings.cognito.domain}/logout?{urlencode(params)}" + ) + + response = RedirectResponse(url=cognito_logout_url, status_code=302) + + for cookie_name in ALB_AUTH_COOKIE_NAMES: + response.set_cookie( + key=cookie_name, + value="", + max_age=0, + expires=0, + path="/", + httponly=True, + secure=True, + samesite="lax", + ) + + return response diff --git a/app/core/settings.py b/app/core/settings.py index fc0ead1..cdb771a 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -153,6 +153,19 @@ class CelerySettings(BaseSettings): task_max_retries: int = 3 +class CognitoSettings(BaseSettings): + """Cognito認証に関する設定。""" + + model_config = SettingsConfigDict( + **COMMON_ENV_CONFIG, + env_prefix="COGNITO_", + ) + + domain: Optional[str] = None + client_id: Optional[str] = None + logout_redirect_uri: Optional[str] = None + + class AppSettings(BaseSettings): """アプリ全体の設定。機密情報は環境変数や .env から読み込む。""" @@ -167,13 +180,14 @@ class AppSettings(BaseSettings): default="sqlite:///./app_dev.sqlite3", alias="DATABASE_URL" ) frontend_url: str = Field( - default="http://localhost:3000", alias="FRONTEND_URL" + default="http://localhost:5173", alias="FRONTEND_URL" ) aws: AWSSettings = Field(default_factory=AWSSettings) llm: LLMSettings = Field(default_factory=LLMSettings) storage: StorageSettings = Field(default_factory=StorageSettings) celery: CelerySettings = Field(default_factory=CelerySettings) + cognito: CognitoSettings = Field(default_factory=CognitoSettings) @property def aws_credentials(self) -> Dict[str, str]: diff --git a/app/main.py b/app/main.py index ace5c4f..e467c78 100644 --- a/app/main.py +++ b/app/main.py @@ -5,9 +5,12 @@ from contextlib import asynccontextmanager from datetime import datetime +from urllib.parse import urlencode +import os -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from starlette.responses import RedirectResponse # --- 分割したルーターと設定関連をインポート --- from app.api import analysis, comments, courses, lectures, metrics, upload @@ -19,7 +22,7 @@ # ---------------------------------------------------------------------- -# アプリケーションファクトリ (テストの容易性のために関数化) +# アプリケーションファクトリ # ---------------------------------------------------------------------- def create_app() -> FastAPI: """Creates and configures the FastAPI application instance.""" @@ -43,9 +46,7 @@ async def lifespan(app: FastAPI): # ------------------------------------------------------------------ # Exception Handlers # ------------------------------------------------------------------ - from fastapi import Request from fastapi.exceptions import RequestValidationError - from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException @app.exception_handler(StarletteHTTPException) @@ -102,32 +103,29 @@ async def general_exception_handler(request: Request, exc: Exception): ) # ------------------------------------------------------------------ - # 1. Middleware + # Middleware # ------------------------------------------------------------------ from app.core.middleware import AuthMiddleware app.add_middleware(AuthMiddleware, debug=config.debug) # ------------------------------------------------------------------ - # 2. ヘルスチェックエンドポイント + # ヘルスチェック # ------------------------------------------------------------------ @app.get("/health", tags=["System"]) def health_check(): - """ - システムの稼働状況を確認するためのヘルスチェックエンドポイント。 - """ return JSONResponse( content={ "status": "ok", "timestamp": datetime.now().isoformat(), "app_name": app.title, "environment": config.env, - # 実際にはDB接続やCeleryキューの状態チェックを追加 } ) + # ------------------------------------------------------------------ - # 3. ルーターの登録 + # API Routers # ------------------------------------------------------------------ app.include_router(upload.router, prefix="/api/v1", tags=["Upload"]) app.include_router(analysis.router, prefix="/api/v1", tags=["Analysis"]) @@ -142,15 +140,13 @@ def health_check(): app.include_router(common.router, prefix="/api/v1", tags=["Common"]) from app.api import trends - app.include_router(trends.router, prefix="/api/v1", tags=["Trends"]) from app.api import dashboard - app.include_router(dashboard.router, prefix="/api/v1", tags=["Dashboard"]) return app -# グローバルなアプリケーションインスタンスを作成 +# グローバルなアプリケーションインスタンス app = create_app() diff --git a/docs/auth-flow.md b/docs/auth-flow.md index b9aa1d9..b7b55d6 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -6,73 +6,114 @@ sequenceDiagram autonumber actor User as ユーザー (Browser) - participant S3 as CloudFront + S3
(Reactアプリ) - participant ALB as ALB
(ロードバランサー) - participant Cognito as Cognito
(認証画面) - participant API as ECS Fargate
(FastAPI) - - Note over User, S3: 1. アプリへの初回アクセス - User->>S3: https://myapp.com へアクセス - S3-->>User: React画面を返す (未ログイン状態) - - Note over User, Cognito: 2. ログインフロー開始 - User->>ALB: 「ログイン」ボタン押下
(任意の保護されたAPI /api/* へアクセス) - ALB-->>User: 302 Redirect (Cognitoへ誘導) - - User->>Cognito: 自動的にAWSのログイン画面へ移動 + participant S3 as CloudFront + S3
(Reactアプリ) + participant ALB as ALB
(門番) + participant Cognito as Cognito
(認証画面) + participant API as ECS Fargate
(FastAPI) + + Note over User, S3: 1. アプリへのアクセス (未認証) + User->>S3: https://myapp.com/ へアクセス + S3-->>User: Reactアプリをロード + + Note over User, ALB: 2. API取得試行と失敗 (自動ログイン判定) + User->>ALB: [fetch] /api/v1/courses (Cookieなし) + ALB-->>User: 302 Redirect (Cognitoへ) + Note right of User: 【重要】fetchはリダイレクトを追跡できず
CORSエラー等で失敗する + + Note over User, Cognito: 3. ログインフロー開始 (ブラウザ全体での遷移) + User->>User: エラーを検知し JSで強制遷移
window.location.href = "/api/v1/login" + User->>ALB: /api/v1/login へアクセス + ALB-->>User: 302 Redirect (Cognito Hosted UIへ) User->>Cognito: ID / Password を入力 Cognito-->>User: 302 Redirect (ALBへ戻る + 認証コード) - Note over User, API: 3. セッション確立 - User->>ALB: 認証コードを持ってALBへアクセス - ALB->>Cognito: 裏でトークン交換と検証 - ALB-->>User: 元の画面へ戻す + Cookie (AWSELBAuthSessionCookie) を付与 - - Note over User, API: 4. 認証済み通信 - User->>ALB: APIリクエスト (Cookie付き) - ALB->>ALB: Cookie検証 (OK) - ALB->>API: リクエスト転送 (x-amzn-oidc-dataヘッダ付与) - API-->>User: データ返却 (JSON) + Note over User, API: 4. セッション確立と「クッション役」のリダイレクト + User->>ALB: /api/v1/login (認証コードを持って再アクセス) + ALB->>Cognito: トークン交換と検証 (バックチャネル) + ALB->>API: リクエストを転送 (/api/v1/login) + Note right of API: JSONは返さず、フロントに戻す指令を出す
return RedirectResponse("/") + API-->>User: 302 Redirect (フロントの "/" へ) + Note left of User: この時、ブラウザに
AWSELBAuthSessionCookie が保存される + + Note over User, S3: 5. 認証済み状態でのアプリ再開 + User->>S3: / へ自動リダイレクト (Cookieを保持) + S3-->>User: Reactアプリを再表示 + + Note over User, API: 6. 認証済みAPI通信 + User->>ALB: [fetch] /api/v1/courses (Cookieが自動で付く) + ALB->>ALB: Cookieを検証 (OK) + ALB->>API: リクエスト転送 (x-amzn-oidc-dataを付与) + API-->>User: 正常なデータ返却 (JSON) + + Note over User, S3: 7. ログアウトフロー (セッションの完全破棄) + User->>S3: ログアウトボタンをクリック + S3->>User: window.location.href = "/api/v1/logout" + + User->>ALB: /api/v1/logout へアクセス (Cookieあり) + ALB->>API: リクエスト転送 + Note right of API: ①Cookie削除のSet-Cookieを付与
②CognitoログアウトURLへ302 + API-->>User: 302 Redirect (Cognito Logout Endpointへ) + + User->>Cognito: GET /logout?client_id=...&logout_uri=... + Note right of Cognito: Cognito側のセッションを終了 + Cognito-->>User: 302 Redirect (アプリのTop "/" へ) + + Note over User, S3: 8. 未認証状態へ戻る + User->>S3: / へリダイレクト (Cookieなし) + S3-->>User: Reactアプリ(未認証状態)を表示 ``` -## フェーズ1:認証(ログインして鍵をもらう) +## フェーズ1:認証(ログインして「鍵」をもらう) -AWSのインフラ(ALB + Cognito)が裏側で複雑な処理をすべて代行する。 +AWSのインフラ(ALB + Cognito)が複雑な認証処理を代行する。Reactアプリ側では、認証が必要な際に「ブラウザごと特定の場所へ移動させる」というシンプルな指示だけで完結する。 + +1. ログインの開始 + + Reactアプリは、APIリクエストのエラー(認証切れ)を検知すると、ユーザーを ログイン専用エンドポイント(例: /api/v1/login) へブラウザごと遷移させる。 + + > 注意:fetch や axios ではALBのリダイレクトを処理できないため、window.location.href を使用する。 + > +2. ALBによる誘導 + + ALBは /api/v1/login へのアクセスをインターセプトし、未認証であればCognitoのHosted UI(AWS標準のログイン画面)へリダイレクトする。 + +3. Cognitoでのユーザー認証 + + ユーザーはAWSの画面でIDとパスワードを入力する。認証成功後、Cognitoは「認証コード」を付与してユーザーを再びALBへ戻す。 + +4. セッション確立と「クッション役」によるリダイレクト + - ALBの処理:裏側でCognitoと通信してトークンを取得し、ブラウザにセッションCookie(`AWSELBAuthSessionCookie`)を発行・保存させる。 + - FastAPIの処理(重要):ALBからリクエストを転送されたFastAPI(`/api/v1/login`)は、「フロントエンドのトップ画面(`/`)に戻れ」というリダイレクト命令(302)を返す。 + - これで、ブラウザのURLがAPIのパスからReactアプリのパスへ正常に戻る。 -1. **ユーザー操作**:Reactアプリ内の「ログイン」ボタンを押す(保護されたAPIを呼び出す)。 -2. **ALBの処理(誘導)** - - リクエストに「鍵(Cookie)」がない場合、ALBは即座にCognitoのHosted UI(AWS標準のログイン画面)へリダイレクトする。 -3. **Cognitoの処理** - - ユーザーはAWSの画面でIDとパスワードを入力する。 - - 認証が成功すると、CognitoはALBへ「認証コード」を持って戻るよう指示する。 -4. **トークンとクッキーの取得** - - ALBは裏側でCognitoと通信し、IDトークンなどを取得する。 - - **重要**:ALBは取得したトークンをユーザーには直接渡さず、代わりに**セッションCookie (`AWSELBAuthSessionCookie`)** を発行してブラウザに保存させる。 - - ユーザーは元のReact画面に戻ってくる。 +## フェーズ2:APIリクエスト(「鍵」を見せて通してもらう) -## フェーズ2:APIリクエスト(鍵を見せて通してもらう) +一度認証が完了すれば、ブラウザの標準機能とALBが連携するため、React側で認証を意識した特別なコードを書く必要はほとんどない。 ### **ステップA:React側でのリクエスト作成** -Reactコード内では、特別なヘッダー(`Authorization`)を作成する必要はない。 -ブラウザの標準機能により、発行された **Cookie (`AWSELBAuthSessionCookie`)** が自動的にリクエストに添付される。 +Reactコード内では、`Authorization` ヘッダーなどを手動でセットする必要はない。ブラウザが自動的に、保存されている Cookie (`AWSELBAuthSessionCookie`) をリクエストに添付して送信する。 ### **ステップB:ネットワーク通過(CloudFront & ALB)** -1. リクエストは `https://myapp.com/api/...` に飛ぶ。 -2. CloudFrontは `/api/*` の設定に基づき、リクエストをALBへ転送する。 - - **重要**:この時、CloudFrontの設定で **Cookieを「転送する(Whitelist または All)」** 設定にしておく必要がある。そうしないと、ALBに届く前に鍵(Cookie)が捨てられてしまい、無限にログイン画面へループしてしまう。 +1. リクエストは `https://myapp.com/api/v1/...` へ送信される。 +2. CloudFrontの役割: + + パスに基づいてALBへ転送する。この際、CloudFront側で Cookieを透過(Forward)する設定にしておく必要がある。そうしないと、ALBに届く前に「鍵」が捨てられ、無限ログインループに陥る。 ### **ステップC:ALBでの検証とFastAPIへの引き渡し** -1. **ALBによる検証(門番)** - - ALBはリクエストに含まれるCookieを検証する。 - - **OK**:Cookieが有効なら、その中の情報を復号し、`x-amzn-oidc-data` というHTTPヘッダーにユーザー情報(JWT)を詰め込んで、FastAPIへ転送する。 - - **NG**:Cookieが無効/期限切れなら、FastAPIには通さず、再度Cognitoのログイン画面へ飛ばす。 -2. **FastAPIでの利用(アプリの処理)** - - FastAPIには、すでに**ALBという信頼できる門番がチェックを済ませたリクエスト**しか届かない。 - - そのため、署名検証などの複雑なチェックは必須ではなくなる(ゼロトラストの観点で行ってもよい)。 - - ヘッダーの `x-amzn-oidc-data` から「誰がアクセスしたか(メールアドレス等)」を取り出し、ビジネスロジックを実行する。 +「門番」であるALBが、届いたCookieの有効性をチェックする。 + +1. 検証OK:Cookie内の情報を復号し、ユーザー属性(JWT)を `x-amzn-oidc-data` ヘッダーに格納してFastAPIへ転送する。 +2. 検証NG(期限切れ等):FastAPIには通さず、再度ログインフロー(ステップ1)へリダイレクトさせる。 + +### **ステップD:FastAPIでのデータ処理** + +FastAPIには「信頼できる門番(ALB)」がチェック済みのリクエストのみが届く。 + +- バックエンド側では、複雑な署名検証の実装は不要だが、ゼロトラストの観点で行うことも可能。 +- ヘッダーの `x-amzn-oidc-data` からユーザーを特定し、ビジネスロジックを実行してJSONを返す。 ## ALBが付与するヘッダー一覧