Skip to content

Commit 36dcd08

Browse files
authored
Merge pull request #87 from kc3hack/feature/issue-71-login-auth
feat: #71 GitHub OAuth認証とスキルツリー自動生成の統合、認証ガード実装
2 parents da177c1 + 3f4c281 commit 36dcd08

File tree

15 files changed

+669
-116
lines changed

15 files changed

+669
-116
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Issue #71: GitHub OAuth認証とスキルツリー生成の統合
2+
3+
## 実装概要
4+
5+
GitHub OAuthを使った認証機能を実装し、認証後にユーザーのGitHubリポジトリを分析してパーソナライズされたスキルツリーを自動生成する機能を統合しました。
6+
7+
## 実装内容
8+
9+
### 1. ログイン画面の改善 (`frontend/src/app/login/page.tsx`)
10+
11+
- **デザイン改善**:
12+
- グラデーション背景の追加
13+
- アイコン追加(🌳)
14+
- アニメーション効果(スライドアップ、シェイク、スライドダウン)
15+
- GitHubログインボタンの視覚効果強化(シャインエフェクト)
16+
- ローディング状態の改善(スピナー表示)
17+
- **UI/UX改善**:
18+
- テストユーザー情報を折りたたみ可能に変更
19+
- エラーメッセージのスタイル改善
20+
- ボタンのホバー/アクティブ状態のアニメーション
21+
22+
### 2. OAuth callback時のProfile自動作成 (`backend/app/api/endpoints/auth.py`)
23+
24+
- **新規ユーザー登録時**:
25+
- User、OAuthAccount、Profileを同一トランザクションで作成
26+
- GitHubのログイン名を `github_username` として自動設定
27+
- アトミック性を保証(一部だけが保存されるゾンビレコードを防止)
28+
29+
- **既存ユーザーログイン時**:
30+
- Profileが存在しない場合は自動作成(マイグレーション対策)
31+
- 失敗してもログインは継続(Warningログのみ)
32+
33+
### 3. Profile CRUD操作の改善 (`backend/app/crud/profile.py`)
34+
35+
- `create_profile()``commit` パラメータを追加
36+
- `commit=False` の場合は `flush()` のみ実行(トランザクション制御用)
37+
38+
### 4. スキルツリーAPIエンドポイントの改善 (`backend/app/api/endpoints/users.py`)
39+
40+
- `/users/me/skill-trees` エンドポイントに `category` クエリパラメータを追加
41+
- カテゴリでフィルタリング可能に
42+
- 未指定の場合は全カテゴリを返却(後方互換性維持)
43+
44+
## 動作フロー
45+
46+
```mermaid
47+
sequenceDiagram
48+
participant U as User
49+
participant F as Frontend
50+
participant B as Backend
51+
participant GH as GitHub
52+
53+
U->>F: ログインページアクセス
54+
U->>F: 「GitHubでログイン」クリック
55+
F->>B: GET /api/v1/auth/github/login
56+
B->>GH: 認可ページへリダイレクト
57+
GH->>U: 認可確認
58+
U->>GH: Authorize
59+
GH->>B: Callback with code
60+
B->>GH: Access Token取得
61+
B->>GH: ユーザー情報取得
62+
B->>B: User + OAuthAccount + Profile作成
63+
B->>F: ダッシュボードへリダイレクト(JWT Cookie付与)
64+
F->>B: GET /users/me/skill-trees?category=web
65+
alt スキルツリーが存在しない
66+
F->>B: POST /analyze/skill-tree
67+
B->>GH: リポジトリ分析
68+
B->>B: スキルツリー生成
69+
B->>F: スキルツリーデータ返却
70+
else スキルツリーが存在する
71+
B->>F: 既存スキルツリー返却
72+
end
73+
F->>U: スキルツリー表示
74+
```
75+
76+
## セットアップ手順
77+
78+
### 1. GitHub OAuth Appの作成
79+
80+
詳細は [docs/GITHUB_OAUTH_SETUP.md](../docs/GITHUB_OAUTH_SETUP.md) を参照してください。
81+
82+
**簡易手順**:
83+
84+
1. [GitHub Developer Settings](https://github.com/settings/developers) にアクセス
85+
2. 「New OAuth App」をクリック
86+
3. 以下を設定:
87+
- Homepage URL: `http://localhost:3000`
88+
- Authorization callback URL: `http://localhost:8000/api/v1/auth/github/callback`
89+
4. Client IDとClient Secretを取得
90+
91+
### 2. 環境変数の設定
92+
93+
`backend/.env` ファイルに以下を設定:
94+
95+
```dotenv
96+
# GitHub OAuth
97+
GITHUB_CLIENT_ID=<取得したClient ID>
98+
GITHUB_CLIENT_SECRET=<取得したClient Secret>
99+
100+
# JWT設定(必須)
101+
JWT_SECRET_KEY=<ランダムな文字列>
102+
103+
# 暗号化キー(必須)
104+
ENCRYPTION_KEY=<Fernetキー>
105+
106+
# GitHub API Token(オプション、Rate Limit緩和)
107+
GITHUB_API_TOKEN=<Personal Access Token>
108+
```
109+
110+
**キー生成方法**:
111+
112+
```bash
113+
# JWT Secret Key
114+
python -c "import secrets; print(secrets.token_hex(32))"
115+
116+
# Encryption Key
117+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
118+
```
119+
120+
### 3. 起動
121+
122+
```bash
123+
# バックエンド
124+
cd backend
125+
poetry install
126+
poetry run uvicorn app.main:app --reload
127+
128+
# フロントエンド(別ターミナル)
129+
cd frontend
130+
npm install
131+
npm run dev
132+
```
133+
134+
### 4. 動作確認
135+
136+
1. `http://localhost:3000/login` にアクセス
137+
2. 「🚀 GitHub でログイン(推奨)」ボタンをクリック
138+
3. GitHubの認可ページで「Authorize」をクリック
139+
4. ダッシュボードにリダイレクトされ、スキルツリーが表示される
140+
141+
## セキュリティ対策
142+
143+
### 実装済みの対策
144+
145+
1. **CSRF対策** (ADR 014):
146+
- HMAC署名付きstateパラメータ
147+
- Cookie バインディング検証
148+
149+
2. **XSS対策**:
150+
- httpOnly Cookie(JavaScriptからアクセス不可)
151+
- JWT を URL パラメータに含めない
152+
153+
3. **トークン暗号化** (ADR 005):
154+
- OAuthアクセストークンはFernetで暗号化してDB保存
155+
- `ENCRYPTION_KEY` が未設定の場合は起動時にエラー
156+
157+
4. **最小権限の原則**:
158+
- GitHub OAuth スコープは `read:user` のみ要求
159+
160+
5. **入力検証**:
161+
- username: 1〜72文字制限(ADR 017)
162+
- password: 1〜128文字制限(PBKDF2 DoS対策)
163+
164+
## テスト
165+
166+
```bash
167+
cd backend
168+
poetry run pytest tests/test_api/test_auth.py -v
169+
poetry run pytest tests/test_services/test_skill_tree_service.py -v
170+
```
171+
172+
## トラブルシューティング
173+
174+
### エラー: "GitHub OAuth は設定されていません"
175+
176+
`.env``GITHUB_CLIENT_ID``GITHUB_CLIENT_SECRET` を確認し、バックエンドを再起動
177+
178+
### エラー: "Invalid or expired state parameter"
179+
180+
→ GitHub OAuth Appの `Authorization callback URL` が正しいか確認
181+
182+
### スキルツリーが表示されない
183+
184+
→ ブラウザの開発者ツールでネットワークタブを確認:
185+
186+
- `/users/me/skill-trees` が 200 を返しているか?
187+
- 404の場合: Profileが作成されていない可能性(DBを確認)
188+
- 500の場合: バックエンドログを確認
189+
190+
## 関連Issue
191+
192+
- Issue #71: GitHub OAuth認証とスキルツリー生成の統合
193+
- Issue #59: GitHub OAuth認証の実装
194+
- Issue #61: 認証統合(Cookie ベース)
195+
- Issue #54: スキルツリー生成(LLM実装)
196+
197+
## ADR参照
198+
199+
- ADR 014: JWT Cookie ベース認証
200+
- ADR 005: OAuthトークンの暗号化保存
201+
- ADR 017: 入力バリデーション戦略

backend/app/api/endpoints/auth.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import hashlib
1717
import hmac
18+
import logging
1819
import secrets
1920
import time
2021
from datetime import datetime, timedelta, timezone
@@ -32,9 +33,11 @@
3233
from app.core.password import verify_password
3334
from app.crud import oauth_account as crud_oauth
3435
from app.crud import user as crud_user
36+
from app.crud import profile as crud_profile
3537
from app.db.session import get_db
3638
from app.schemas.oauth_account import OAuthAccountCreate, OAuthTokenUpdate
3739
from app.schemas.user import UserCreate
40+
from app.schemas.profile import ProfileCreate
3841

3942
router = APIRouter()
4043

@@ -286,16 +289,32 @@ async def github_callback(
286289
OAuthTokenUpdate(access_token=access_token),
287290
)
288291
db_user = crud_user.get_user(db, existing_oauth.user_id)
292+
293+
# 既存ユーザーでもProfileが存在しない場合は作成(マイグレーション対策: Issue #71)
294+
if db_user and not crud_profile.get_profile_by_user_id(db, db_user.id):
295+
try:
296+
crud_profile.create_profile(
297+
db,
298+
ProfileCreate(
299+
user_id=db_user.id,
300+
github_username=github_login_name,
301+
),
302+
)
303+
except Exception as e:
304+
# Profileの作成に失敗してもログインは継続(Warning のみ)
305+
logger = logging.getLogger(__name__)
306+
logger.warning(
307+
f"Failed to create profile for user_id={db_user.id}: {e}"
308+
)
289309
else:
290310
# 新規登録: username の重複回避
291311
username = github_login_name
292312
if crud_user.get_user_by_username(db, username) is not None:
293313
username = f"{github_login_name}_{github_user_id}"
294314

295-
# User と OAuthAccount を同一トランザクションで作成(アトミック性保証)
296-
# commit=False にして両方を flush した後、一括 commit する。
315+
# User と OAuthAccount と Profile を同一トランザクションで作成(アトミック性保証)
316+
# commit=False にして全てを flush した後、一括 commit する。
297317
# これにより「User だけが永続化されるゾンビレコード」を防ぐ。
298-
# create_user / create_oauth_account の flush も含めて全体を囲う(C-1修正)
299318
try:
300319
db_user = crud_user.create_user(
301320
db, UserCreate(username=username), commit=False
@@ -311,6 +330,15 @@ async def github_callback(
311330
),
312331
commit=False,
313332
)
333+
# Profile 作成(スキルツリー生成に必要: Issue #71)
334+
crud_profile.create_profile(
335+
db,
336+
ProfileCreate(
337+
user_id=db_user.id,
338+
github_username=github_login_name,
339+
),
340+
commit=False,
341+
)
314342
db.commit()
315343
except Exception as e:
316344
db.rollback()

backend/app/api/endpoints/users.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
/users/{id} 系の管理者向けエンドポイントは、別途管理 API モジュール/Issue で扱う。
99
"""
1010

11-
from fastapi import APIRouter, Depends, HTTPException
11+
from fastapi import APIRouter, Depends, HTTPException, Query
1212
from sqlalchemy.orm import Session
1313

1414
from app.crud import badge as crud_badge
@@ -19,6 +19,7 @@
1919
from app.db.session import get_db
2020
from app.dependencies.auth import get_current_user
2121
from app.models.user import User
22+
from app.models.enums import SkillCategory
2223
from app.schemas.badge import Badge as BadgeSchema
2324
from app.schemas.profile import Profile as ProfileSchema
2425
from app.schemas.profile import ProfileCreate, ProfileUpdate
@@ -103,11 +104,33 @@ def get_my_badges(
103104

104105
@router.get("/me/skill-trees", response_model=list[SkillTreeSchema])
105106
def get_my_skill_trees(
107+
category: str | None = Query(
108+
None,
109+
description="スキルカテゴリでフィルタ(web/ai/security/infrastructure/game/design)",
110+
),
106111
db: Session = Depends(get_db),
107112
current_user: User = Depends(get_current_user),
108113
) -> list[SkillTreeSchema]:
109-
"""認証済みユーザー自身のスキルツリー一覧取得(6カテゴリ)。"""
110-
return crud_skill_tree.get_skill_trees_by_user(db, current_user.id)
114+
"""認証済みユーザー自身のスキルツリー取得。
115+
116+
category パラメータが指定された場合は該当カテゴリのみ返却。
117+
未指定の場合は全カテゴリ(6カテゴリ)を返却。
118+
"""
119+
all_trees = crud_skill_tree.get_skill_trees_by_user(db, current_user.id)
120+
121+
if category:
122+
# categoryでフィルタリング
123+
try:
124+
# 文字列をEnumに変換して検証
125+
cat_enum = SkillCategory(category.lower())
126+
return [tree for tree in all_trees if tree.category == cat_enum.value]
127+
except ValueError:
128+
raise HTTPException(
129+
status_code=400,
130+
detail=f"Invalid category: {category}. Valid values: web, ai, security, infrastructure, game, design",
131+
)
132+
133+
return all_trees
111134

112135

113136
@router.get("/me/quest-progress", response_model=list[QuestProgressSchema])

backend/app/crud/profile.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,35 @@ def get_profile_by_user_id(db: Session, user_id: int) -> Profile | None:
1111
return db.query(Profile).filter(Profile.user_id == user_id).first()
1212

1313

14-
def create_profile(db: Session, profile_in: ProfileCreate) -> Profile:
14+
def create_profile(
15+
db: Session, profile_in: ProfileCreate, commit: bool = True
16+
) -> Profile:
1517
data = profile_in.model_dump()
1618
# HttpUrlをstrに変換
1719
if data.get("portfolio_url"):
1820
data["portfolio_url"] = str(data["portfolio_url"])
1921
db_profile = Profile(**data)
2022
db.add(db_profile)
21-
try:
22-
db.commit()
23-
except IntegrityError as e:
24-
db.rollback()
25-
raise ValueError(
26-
f"Profile for user_id={profile_in.user_id} already exists"
27-
) from e
28-
except Exception:
29-
db.rollback()
30-
raise
31-
db.refresh(db_profile)
23+
if commit:
24+
try:
25+
db.commit()
26+
except IntegrityError as e:
27+
db.rollback()
28+
raise ValueError(
29+
f"Profile for user_id={profile_in.user_id} already exists"
30+
) from e
31+
except Exception:
32+
db.rollback()
33+
raise
34+
db.refresh(db_profile)
35+
else:
36+
try:
37+
db.flush()
38+
except IntegrityError as e:
39+
db.rollback()
40+
raise ValueError(
41+
f"Profile for user_id={profile_in.user_id} already exists"
42+
) from e
3243
return db_profile
3344

3445

0 commit comments

Comments
 (0)