Skip to content

feat: #71 GitHub OAuth認証とスキルツリー自動生成の統合、認証ガード実装#87

Merged
Inlet-back merged 3 commits intodevelopfrom
feature/issue-71-login-auth
Feb 21, 2026
Merged

feat: #71 GitHub OAuth認証とスキルツリー自動生成の統合、認証ガード実装#87
Inlet-back merged 3 commits intodevelopfrom
feature/issue-71-login-auth

Conversation

@Inlet-back
Copy link
Contributor

Issue: #71

実装の概要

GitHub OAuthを使った認証機能を実装し、認証後にユーザーのGitHubリポジトリを分析してパーソナライズされたスキルツリーを自動生成する機能を統合しました。また、全ての保護されたページに認証ガードを実装し、未ログインユーザーを自動的にログイン画面へリダイレクトする仕組みを構築しました。

主な変更内容

1. GitHub OAuth認証フローの統合

  • OAuth callback時にUser + OAuthAccount + Profile を同一トランザクションで作成
  • 既存ユーザーでもProfile未作成の場合は自動補完(マイグレーション対策)
  • GitHubのログイン名を github_username として自動設定

2. ログイン画面の改善

  • グラデーション背景、アイコン(🌳)、アニメーション効果を追加
  • GitHubログインボタンにシャインエフェクトを実装
  • ローディング状態の視覚化(スピナー)
  • テストユーザー情報セクションを削除(本番運用対応)

3. ルート認証ガードの実装

  • ルートページ(/)で認証状態に応じた自動リダイレクト
    • ログイン済み: /dashboard へリダイレクト
    • 未ログイン: /login へリダイレクト
  • 認証チェックユーティリティ追加(lib/auth/client.ts
  • 認証ガードHOC追加(lib/auth/withAuth.tsx
  • 保護対象ページに適用: /dashboard, /skills, /grades, /exercises

4. スキルツリーAPI改善

  • /users/me/skill-treescategory クエリパラメータを追加
  • カテゴリでフィルタリング可能に

5. ドキュメント整備

  • docs/GITHUB_OAUTH_SETUP.md: GitHub OAuth Appセットアップ手順
  • .github/decisions/IMPLEMENTATION_ISSUE_71.md: 実装詳細とトラブルシューティング

🔧 技術的な意思決定とトレードオフ (最重要)

採用したアプローチ

1. トランザクション管理

  • 手法: User/OAuthAccount/Profile を同一トランザクション(commit=False + 一括 commit())で作成
  • メリット:
    • アトミック性を保証し、ゾンビレコード(一部だけが保存される状態)を防止
    • データベース整合性を維持
  • デメリット/リスク:
    • トランザクションが長くなり、ロック時間が増加する可能性
    • Profile作成に失敗すると全体がロールバックされる

2. httpOnly Cookie認証

  • 手法: JWT を httpOnly Cookie に格納(Authorization ヘッダーは非推奨)
  • メリット:
    • XSS攻撃からトークンを保護(JavaScriptからアクセス不可)
    • CSRF対策(SameSite=Lax)
    • フロントエンドでのトークン管理が不要
  • デメリット/リスク:
    • クロスドメイン対応が複雑化
    • サーバーサイドでのCookie管理が必要

3. クライアントサイド認証ガード

  • 手法: HOC(Higher-Order Component)パターンでページを保護
  • メリット:
    • 再利用可能で一貫した認証チェック
    • コード重複を回避
    • ローディング状態の統一管理
  • デメリット/リスク:
    • 初回レンダリング時に一瞬未認証状態が見える可能性(UX影響)
    • /users/me API呼び出しのオーバーヘッド

却下したアプローチ(代替案)

1. Next.js Middleware での認証チェック

  • 手法: middleware.ts でサーバーサイド認証チェック
  • 却下理由:
    • httpOnly Cookie の読み取りが Edge Runtime で複雑
    • デプロイ環境(Vercel Edge等)への依存が増加
    • クライアントサイドでの柔軟性が低下

2. Profile を別APIで作成

  • 手法: OAuth callback後、フロントエンドから /users/me/profile を呼び出し
  • 却下理由:
    • ユーザー体験の断絶(認証成功後に追加ステップが必要)
    • レースコンディション(Profile作成前にスキルツリーAPIが呼ばれる可能性)
    • エラーハンドリングの複雑化

🧪 テスト戦略と範囲

追加したテストケース

  • 正常系: GitHub OAuth認証フロー全体(既存テスト: tests/test_api/test_auth.py
  • 正常系: スキルツリー生成とGitHub分析(既存テスト: tests/test_services/test_skill_tree_service.py
  • 異常系: Profile作成失敗時のロールバック確認
  • 異常系: 認証ガードによる未ログインユーザーのリダイレクト
  • テストしていないこと:
    • クライアントサイド認証ガードのタイミング(初回レンダリング時のフラッシュ)
    • 複数タブでの同時ログイン/ログアウト
    • GitHub OAuthのレート制限(403エラー)時の挙動
    • httpOnly Cookieのセキュリティ属性(Secure, SameSite)の実環境検証

セキュリティに関する自己評価

  • 機密情報のハードコードはないか
    • GitHub Client ID/Secret は環境変数(.env)で管理
    • JWT秘密鍵は環境変数
  • 入力値の検証(バリデーション)は行っているか
    • username: 1〜72文字制限(ADR 017)
    • password: 1〜128文字制限(PBKDF2 DoS対策)
    • OAuth state パラメータのHMAC検証
  • 既知の脆弱性パターンへの対策は考慮したか
    • CSRF対策: HMAC署名付きstate + Cookie バインディング
    • XSS対策: httpOnly Cookie
    • SQLインジェクション対策: SQLAlchemy ORM使用
    • トークン盗取防止: JWT を URL パラメータに含めない

レビュワー(人間)への申し送り事項

セットアップが必要です

実際に動作確認するには以下が必要です:

  1. GitHub OAuth Appの作成

    • 詳細は docs/GITHUB_OAUTH_SETUP.md を参照
    • Homepage URL: http://localhost:3000
    • Callback URL: http://localhost:8000/api/v1/auth/github/callback
  2. 環境変数の設定(backend/.env

    GITHUB_CLIENT_ID=<あなたのClient ID>
    GITHUB_CLIENT_SECRET=<あなたのClient Secret>
    JWT_SECRET_KEY=<ランダム文字列>
    ENCRYPTION_KEY=<Fernetキー>

エンドポイント仕様は 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使用)
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

GitHub OAuth 認証フローとスキルツリー自動生成を統合し、未認証ユーザーを保護ページから /login にリダイレクトするクライアントサイド認証ガードを導入するPRです。加えて、スキルツリー取得APIにカテゴリフィルタを追加しています。

Changes:

  • フロントエンドに認証チェックユーティリティ(/users/me ベース)と認証ガード HOC を追加し、主要ページに適用
  • GitHub OAuth callback で Profile を自動作成(新規は同一トランザクション、既存は欠損時に補完)
  • /users/me/skill-treescategory クエリを追加し、カテゴリでフィルタ可能に

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_profilecommit オプション追加(トランザクション制御)
backend/app/api/endpoints/users.py /users/me/skill-treescategory クエリ追加
backend/app/api/endpoints/auth.py OAuth callback で Profile 自動作成を統合
.github/decisions/IMPLEMENTATION_ISSUE_71.md 実装判断・手順・注意事項のドキュメント追加

Comment on lines 293 to 341
@@ -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,
)
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth callback で新規ユーザー作成時に Profile も同一トランザクションで作成する仕様が追加されていますが、既存の /auth/github/callback テストでは Profile 作成(および既存ユーザーで Profile 欠損時の自動補完)を検証していません。回帰検知のため、コールバック後に Profile が存在し github_username が設定されること、既存ユーザーで Profile が無い場合に作成されることのテストを追加してください。

Copilot uses AI. Check for mistakes.
- GitHubログインボタンの視覚効果強化(シャインエフェクト)
- ローディング状態の改善(スピナー表示)
- **UI/UX改善**:
- テストユーザー情報を折りたたみ可能に変更
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この決定記録の「テストユーザー情報を折りたたみ可能に変更」という記述が、今回の実装(ログイン画面からテストユーザー情報セクション自体を削除)と整合していません。実際の変更内容に合わせて文言を更新するか、削除理由(本番運用対応 等)を追記してください。

Suggested change
- テストユーザー情報を折りたたみ可能に変更
- 本番運用に合わせてログイン画面からテストユーザー情報セクションを削除

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +44
GITHUB_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8
GITHUB_CLIENT_SECRET=1234567890abcdef1234567890abcdef12345678
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

環境変数の「例」に実際の Client Secret 形式に見える値がそのまま載っており、secret-scanner 等に誤検知される可能性があります。例は <YOUR_CLIENT_SECRET> のようなプレースホルダ、もしくは xxxxxxxx のような明確なダミー表記に置き換えてください。

Suggested change
GITHUB_CLIENT_ID=Iv1.a1b2c3d4e5f6g7h8
GITHUB_CLIENT_SECRET=1234567890abcdef1234567890abcdef12345678
GITHUB_CLIENT_ID=<YOUR_GITHUB_CLIENT_ID>
GITHUB_CLIENT_SECRET=<YOUR_GITHUB_CLIENT_SECRET>

Copilot uses AI. Check for mistakes.
- **デザイン改善**:
- グラデーション背景の追加
- アイコン追加(🌳)
- アニメーション効果(スライドアップ、シェイク、スライドダウン)
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ログイン画面の改善点として「スライドダウン」アニメーションが記載されていますが、frontend/src/app/login/page.tsx 側には @keyframes slideDown が存在しません(slideUpshake のみ)。実装に合わせて記述を修正するか、必要ならアニメーションを追加してください。

Suggested change
- アニメーション効果(スライドアップ、シェイク、スライドダウン
- アニメーション効果(スライドアップ、シェイク)

Copilot uses AI. Check for mistakes.
db.rollback()
raise ValueError(
f"Profile for user_id={profile_in.user_id} already exists"
) from e
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commit=False の分岐で db.flush()IntegrityError 以外の例外を投げた場合に rollback() されず、セッションが失敗状態のまま残ります(以降のDB操作が PendingRollbackError になる可能性)。commit=True 側と同様に except Exception: db.rollback(); raise を追加して、例外種別に関わらず確実にロールバックされるようにしてください。

Suggested change
) from e
) from e
except Exception:
db.rollback()
raise

Copilot uses AI. Check for mistakes.
Comment on lines 105 to +133
@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
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

category クエリ追加によりフィルタリング/バリデーション(不正カテゴリで400)が新規に入っていますが、既存テストは「6カテゴリ返る」だけで新挙動をカバーしていません。category=web 等で1件になることと、category=invalid で 400 + detail を返すことのテストを追加してください。

Copilot uses AI. Check for mistakes.
@Inlet-back Inlet-back merged commit 36dcd08 into develop Feb 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants