feat: #71 GitHub OAuth認証とスキルツリー自動生成の統合、認証ガード実装#87
Conversation
エンドポイント仕様は ADR 011 / ADR 015 参照。
認証統合方針は ADR 014 / ADR 015 参照。
rank 管理方針は ADR 010 参照。
/users/me → 認証済みユーザー自身の操作のみ提供(ADR 015)
/users/{id} 系の管理者向けエンドポイントは、別途管理 API モジュール/Issue で扱う。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.crud import badge as crud_badge
from app.crud import profile as crud_profile
from app.crud import quest_progress as crud_quest_progress
from app.crud import skill_tree as crud_skill_tree
from app.crud import user as crud_user
from app.db.session import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.models.enums import SkillCategory
from app.schemas.badge import Badge as BadgeSchema
from app.schemas.profile import Profile as ProfileSchema
from app.schemas.profile import ProfileCreate, ProfileUpdate
from app.schemas.quest_progress import QuestProgress as QuestProgressSchema
from app.schemas.skill_tree import SkillTree as SkillTreeSchema
from app.schemas.user import User as UserSchema
from app.schemas.user import UserUpdate
router = APIRouter()
# ---------------------------------------------------------------------------
# /users/me 認証済みユーザー自身の操作 (ADR 015)
# ---------------------------------------------------------------------------
@router.get("/me", response_model=UserSchema)
def get_me(current_user: User = Depends(get_current_user)) -> UserSchema:
"""認証済みユーザー自身の情報取得。"""
return current_user
@router.put("/me", response_model=UserSchema)
def update_me(
user_in: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserSchema:
"""認証済みユーザー自身の username 更新。"""
try:
user = crud_user.update_user(db, current_user.id, user_in)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.delete("/me", status_code=204)
def delete_me(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
"""認証済みユーザー自身のアカウント削除。"""
crud_user.delete_user(db, current_user.id)
@router.get("/me/profile", response_model=ProfileSchema)
def get_my_profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ProfileSchema:
"""認証済みユーザー自身のプロフィール取得。"""
profile = crud_profile.get_profile_by_user_id(db, current_user.id)
if profile is None:
raise HTTPException(status_code=404, detail="Profile not found")
return profile
@router.put("/me/profile", response_model=ProfileSchema)
def upsert_my_profile(
profile_in: ProfileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ProfileSchema:
"""認証済みユーザー自身のプロフィール更新(Upsert)。"""
profile = crud_profile.get_profile_by_user_id(db, current_user.id)
if profile is None:
create_data = ProfileCreate(user_id=current_user.id, **profile_in.model_dump())
return crud_profile.create_profile(db, create_data)
return crud_profile.update_profile(db, profile.id, profile_in)
@router.get("/me/badges", response_model=list[BadgeSchema])
def get_my_badges(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[BadgeSchema]:
"""認証済みユーザー自身のバッジ一覧取得。"""
return crud_badge.get_badges_by_user(db, current_user.id)
@router.get("/me/skill-trees", response_model=list[SkillTreeSchema])
def category: str | None = Query(None, description="スキルカテゴリでフィルタ(web/ai/security/infrastructure/game/design)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[SkillTreeSchema]:
"""認証済みユーザー自身のスキルツリー取得。
category パラメータが指定された場合は該当カテゴリのみ返却。
未指定の場合は全カテゴリ(6カテゴリ)を返却。
"""
all_trees = crud_skill_tree.get_skill_trees_by_user(db, current_user.id)
if category:
# categoryでフィルタリング
try:
# 文字列をEnumに変換して検証
cat_enum = SkillCategory(category.lower())
return [tree for tree in all_trees if tree.category == cat_enum.value]
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid category: {category}. Valid values: web, ai, security, infrastructure, game, design"
)
return all_trees
return crud_skill_tree.get_skill_trees_by_user(db, current_user.id)
@router.get("/me/quest-progress", response_model=list[QuestProgressSchema])
def get_my_quest_progress(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[QuestProgressSchema]:
"""認証済みユーザー自身のクエスト進捗一覧取得。"""
return crud_quest_progress.get_quest_progress_by_user(db, current_user.id)
変更内容: - ルートページ(/)で認証状態に応じたリダイレクト実装 - ログイン済み: /dashboard へリダイレクト - 未ログイン: /login へリダイレクト - 認証チェックユーティリティ追加(lib/auth/client.ts) - isAuthenticated(): Cookie認証状態確認 - getCurrentUser(): ユーザー情報取得 - 認証ガードHOC追加(lib/auth/withAuth.tsx) - 未ログインユーザーを/loginへ自動リダイレクト - ローディング中の統一スピナー表示 - 保護対象ページに認証ガード適用 - /dashboard, /skills, /grades, /exercises - テストユーザー情報セクションを削除(本番環境対応) 実装の意図: - httpOnly Cookie認証に基づく一貫した認証フロー提供 - 保護されたページへの未認証アクセスを防止 - ユーザー体験の向上(適切なページへの自動リダイレクト) - 本番運用に適したクリーンなログイン画面 セキュリティ対策: - 全ての保護されたページで認証チェック実施 - httpOnly Cookieによるトークン管理(XSS対策) - クライアントサイドでの認証状態検証(/users/me API使用)
There was a problem hiding this comment.
Pull request overview
GitHub OAuth 認証フローとスキルツリー自動生成を統合し、未認証ユーザーを保護ページから /login にリダイレクトするクライアントサイド認証ガードを導入するPRです。加えて、スキルツリー取得APIにカテゴリフィルタを追加しています。
Changes:
- フロントエンドに認証チェックユーティリティ(
/users/meベース)と認証ガード HOC を追加し、主要ページに適用 - GitHub OAuth callback で Profile を自動作成(新規は同一トランザクション、既存は欠損時に補完)
/users/me/skill-treesにcategoryクエリを追加し、カテゴリでフィルタ可能に
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/lib/auth/withAuth.tsx | クライアントサイドの認証ガードHOCを追加 |
| frontend/src/lib/auth/client.ts | /users/me による認証状態/ユーザー取得ユーティリティを追加 |
| frontend/src/features/dashboard/components/DashboardContainer.tsx | ダッシュボード取得対象を me 前提に変更(UI側の統合) |
| frontend/src/app/skills/page.tsx | withAuth を適用してページを保護 |
| frontend/src/app/page.tsx | ルート / を認証状態に応じて /dashboard or /login へ誘導 |
| frontend/src/app/login/page.tsx | ログイン画面のUI改善とテストユーザー表示削除 |
| frontend/src/app/grades/page.tsx | withAuth を適用してページを保護 |
| frontend/src/app/exercises/page.tsx | withAuth を適用してページを保護 |
| frontend/src/app/dashboard/page.tsx | withAuth を適用し、Container 呼び出しを更新 |
| docs/GITHUB_OAUTH_SETUP.md | GitHub OAuth App セットアップ手順を追加 |
| backend/app/crud/profile.py | create_profile に commit オプション追加(トランザクション制御) |
| backend/app/api/endpoints/users.py | /users/me/skill-trees に category クエリ追加 |
| backend/app/api/endpoints/auth.py | OAuth callback で Profile 自動作成を統合 |
| .github/decisions/IMPLEMENTATION_ISSUE_71.md | 実装判断・手順・注意事項のドキュメント追加 |
| @@ -311,6 +330,15 @@ async def github_callback( | |||
| ), | |||
| commit=False, | |||
| ) | |||
| # Profile 作成(スキルツリー生成に必要: Issue #71) | |||
| crud_profile.create_profile( | |||
| db, | |||
| ProfileCreate( | |||
| user_id=db_user.id, | |||
| github_username=github_login_name, | |||
| ), | |||
| commit=False, | |||
| ) | |||
There was a problem hiding this comment.
OAuth callback で新規ユーザー作成時に Profile も同一トランザクションで作成する仕様が追加されていますが、既存の /auth/github/callback テストでは Profile 作成(および既存ユーザーで Profile 欠損時の自動補完)を検証していません。回帰検知のため、コールバック後に Profile が存在し github_username が設定されること、既存ユーザーで Profile が無い場合に作成されることのテストを追加してください。
| - GitHubログインボタンの視覚効果強化(シャインエフェクト) | ||
| - ローディング状態の改善(スピナー表示) | ||
| - **UI/UX改善**: | ||
| - テストユーザー情報を折りたたみ可能に変更 |
There was a problem hiding this comment.
この決定記録の「テストユーザー情報を折りたたみ可能に変更」という記述が、今回の実装(ログイン画面からテストユーザー情報セクション自体を削除)と整合していません。実際の変更内容に合わせて文言を更新するか、削除理由(本番運用対応 等)を追記してください。
| - テストユーザー情報を折りたたみ可能に変更 | |
| - 本番運用に合わせてログイン画面からテストユーザー情報セクションを削除 |
| GITHUB_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8 | ||
| GITHUB_CLIENT_SECRET=1234567890abcdef1234567890abcdef12345678 |
There was a problem hiding this comment.
環境変数の「例」に実際の Client Secret 形式に見える値がそのまま載っており、secret-scanner 等に誤検知される可能性があります。例は <YOUR_CLIENT_SECRET> のようなプレースホルダ、もしくは xxxxxxxx のような明確なダミー表記に置き換えてください。
| GITHUB_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8 | |
| GITHUB_CLIENT_SECRET=1234567890abcdef1234567890abcdef12345678 | |
| GITHUB_CLIENT_ID=<YOUR_GITHUB_CLIENT_ID> | |
| GITHUB_CLIENT_SECRET=<YOUR_GITHUB_CLIENT_SECRET> |
| - **デザイン改善**: | ||
| - グラデーション背景の追加 | ||
| - アイコン追加(🌳) | ||
| - アニメーション効果(スライドアップ、シェイク、スライドダウン) |
There was a problem hiding this comment.
ログイン画面の改善点として「スライドダウン」アニメーションが記載されていますが、frontend/src/app/login/page.tsx 側には @keyframes slideDown が存在しません(slideUp と shake のみ)。実装に合わせて記述を修正するか、必要ならアニメーションを追加してください。
| - アニメーション効果(スライドアップ、シェイク、スライドダウン) | |
| - アニメーション効果(スライドアップ、シェイク) |
| db.rollback() | ||
| raise ValueError( | ||
| f"Profile for user_id={profile_in.user_id} already exists" | ||
| ) from e |
There was a problem hiding this comment.
commit=False の分岐で db.flush() が IntegrityError 以外の例外を投げた場合に rollback() されず、セッションが失敗状態のまま残ります(以降のDB操作が PendingRollbackError になる可能性)。commit=True 側と同様に except Exception: db.rollback(); raise を追加して、例外種別に関わらず確実にロールバックされるようにしてください。
| ) from e | |
| ) from e | |
| except Exception: | |
| db.rollback() | |
| raise |
| @router.get("/me/skill-trees", response_model=list[SkillTreeSchema]) | ||
| def get_my_skill_trees( | ||
| category: str | None = Query( | ||
| None, | ||
| description="スキルカテゴリでフィルタ(web/ai/security/infrastructure/game/design)", | ||
| ), | ||
| db: Session = Depends(get_db), | ||
| current_user: User = Depends(get_current_user), | ||
| ) -> list[SkillTreeSchema]: | ||
| """認証済みユーザー自身のスキルツリー一覧取得(6カテゴリ)。""" | ||
| return crud_skill_tree.get_skill_trees_by_user(db, current_user.id) | ||
| """認証済みユーザー自身のスキルツリー取得。 | ||
|
|
||
| category パラメータが指定された場合は該当カテゴリのみ返却。 | ||
| 未指定の場合は全カテゴリ(6カテゴリ)を返却。 | ||
| """ | ||
| all_trees = crud_skill_tree.get_skill_trees_by_user(db, current_user.id) | ||
|
|
||
| if category: | ||
| # categoryでフィルタリング | ||
| try: | ||
| # 文字列をEnumに変換して検証 | ||
| cat_enum = SkillCategory(category.lower()) | ||
| return [tree for tree in all_trees if tree.category == cat_enum.value] | ||
| except ValueError: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"Invalid category: {category}. Valid values: web, ai, security, infrastructure, game, design", | ||
| ) | ||
|
|
||
| return all_trees |
There was a problem hiding this comment.
category クエリ追加によりフィルタリング/バリデーション(不正カテゴリで400)が新規に入っていますが、既存テストは「6カテゴリ返る」だけで新挙動をカバーしていません。category=web 等で1件になることと、category=invalid で 400 + detail を返すことのテストを追加してください。
Issue: #71
実装の概要
GitHub OAuthを使った認証機能を実装し、認証後にユーザーのGitHubリポジトリを分析してパーソナライズされたスキルツリーを自動生成する機能を統合しました。また、全ての保護されたページに認証ガードを実装し、未ログインユーザーを自動的にログイン画面へリダイレクトする仕組みを構築しました。
主な変更内容
1. GitHub OAuth認証フローの統合
github_usernameとして自動設定2. ログイン画面の改善
3. ルート認証ガードの実装
/dashboardへリダイレクト/loginへリダイレクトlib/auth/client.ts)lib/auth/withAuth.tsx)/dashboard,/skills,/grades,/exercises4. スキルツリーAPI改善
/users/me/skill-treesにcategoryクエリパラメータを追加5. ドキュメント整備
docs/GITHUB_OAUTH_SETUP.md: GitHub OAuth Appセットアップ手順.github/decisions/IMPLEMENTATION_ISSUE_71.md: 実装詳細とトラブルシューティング🔧 技術的な意思決定とトレードオフ (最重要)
採用したアプローチ
1. トランザクション管理
commit=False+ 一括commit())で作成2. httpOnly Cookie認証
Authorizationヘッダーは非推奨)3. クライアントサイド認証ガード
/users/meAPI呼び出しのオーバーヘッド却下したアプローチ(代替案)
1. Next.js Middleware での認証チェック
middleware.tsでサーバーサイド認証チェック2. Profile を別APIで作成
/users/me/profileを呼び出し🧪 テスト戦略と範囲
追加したテストケース
tests/test_api/test_auth.py)tests/test_services/test_skill_tree_service.py)セキュリティに関する自己評価
.env)で管理レビュワー(人間)への申し送り事項
セットアップが必要です
実際に動作確認するには以下が必要です:
GitHub OAuth Appの作成
docs/GITHUB_OAUTH_SETUP.mdを参照http://localhost:3000http://localhost:8000/api/v1/auth/github/callback環境変数の設定(
backend/.env)