From 54962f01a182bfbaaa8090b2d904767cdca3bbd0 Mon Sep 17 00:00:00 2001 From: mikuto <152457695+Mikuto0831@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:27:51 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20authentik=20OIDC=20=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=83=90=E3=82=A4=E3=83=80=E3=83=BC=E3=81=A8=20admin?= =?UTF-8?q?=20SSO=20=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E5=90=8C=E6=9C=9F?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authentik を OIDC プロバイダーとして追加 (AUTHENTIK_CLIENT_ID 等の env var で有効化) - OIDCClaims に Groups フィールドを追加 (ID Token の groups クレームを抽出) - ADMIN_GROUPS 環境変数でグループ名を指定すると、ログイン時に自動でロールを同期: グループ一致 → role = admin / 不一致 → role = student 未設定のときは従来通りロールを変更しない (手動 DB UPDATE で昇格) - UserRepository.UpsertWithIdentity に roleHint を追加 既存ユーザーと新規ユーザーの両方でロールを上書き可能に authentik の設定手順: 1. Provider に OAuth2/OIDC を作成し Client ID/Secret を控える 2. Scopes に groups マッピングを追加 (Property Mapping で groups クレームを発行) 3. ADMIN_GROUPS に authentik 上の admin グループ名を設定 Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/main.go | 27 ++++++++++++++++- backend/internal/domain/auth.go | 3 ++ .../infrastructure/db/repository/user.go | 20 ++++++++----- .../internal/infrastructure/oidc/verifier.go | 14 +++++---- backend/internal/usecase/auth.go | 30 +++++++++++++++++-- 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3446bfa..e830883 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -31,6 +31,7 @@ func main() { jwtSecret := []byte(getEnv("JWT_SECRET", "dev-secret-change-in-production")) bypass := getEnv("AUTH_BYPASS", "") == "true" allowDomains := splitCSV(os.Getenv("ALLOWED_DOMAINS")) + adminGroups := splitCSV(os.Getenv("ADMIN_GROUPS")) // ── DB マイグレーション ── log.Println("running migrations...") @@ -78,7 +79,7 @@ func main() { submitHandler := handler.NewSubmitHandler(judgeUsecase) problemHandler := handler.NewProblemHandler(problemRepo) submissionHandler := handler.NewSubmissionHandler(submissionRepo) - authUsecase := usecase.NewAuthUsecase(userRepo, allowedEmailRepo, allowDomains, jwtSecret) + authUsecase := usecase.NewAuthUsecase(userRepo, allowedEmailRepo, allowDomains, jwtSecret, adminGroups) authHandler := handler.NewAuthHandler(authUsecase, providers, frontendOrigin, bypass) // ── Echo ルーター ── @@ -171,6 +172,30 @@ func buildProviders(ctx context.Context) []*handler.ProviderConfig { log.Println("oidc provider registered: keycloak") } + // authentik は標準 OIDC に準拠しており Keycloak と同じフローで動作する。 + // groups クレームを ID Token に含める場合は authentik 側で Property Mapping を設定すること: + // Provider → Edit → Advanced → Scopes に groups マッピングを追加 + if clientID := os.Getenv("AUTHENTIK_CLIENT_ID"); clientID != "" { + issuer := os.Getenv("AUTHENTIK_ISSUER") + p, err := oidc.NewProvider(ctx, issuer) + if err != nil { + log.Fatalf("init authentik oidc provider: %v", err) + } + providers = append(providers, &handler.ProviderConfig{ + Name: "authentik", + OAuth2: &oauth2.Config{ + ClientID: clientID, + ClientSecret: os.Getenv("AUTHENTIK_CLIENT_SECRET"), + RedirectURL: os.Getenv("AUTHENTIK_CALLBACK_URL"), + // openid/email/profile に加え groups クレームを要求する + Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, + Endpoint: p.Endpoint(), + }, + Verifier: oidcinfra.NewVerifier(p.Verifier(&oidc.Config{ClientID: clientID})), + }) + log.Println("oidc provider registered: authentik") + } + return providers } diff --git a/backend/internal/domain/auth.go b/backend/internal/domain/auth.go index 8b1a791..c89f9bb 100644 --- a/backend/internal/domain/auth.go +++ b/backend/internal/domain/auth.go @@ -8,6 +8,9 @@ type OIDCClaims struct { Email string EmailVerified bool Name string + // Groups はプロバイダーが発行したグループ一覧 (例: authentik の groups claim)。 + // グループクレームを持たないプロバイダー (Google 等) では nil になる。 + Groups []string } // AppClaims はアプリ独自 JWT のペイロード diff --git a/backend/internal/infrastructure/db/repository/user.go b/backend/internal/infrastructure/db/repository/user.go index d16378b..8a7ad03 100644 --- a/backend/internal/infrastructure/db/repository/user.go +++ b/backend/internal/infrastructure/db/repository/user.go @@ -49,7 +49,7 @@ func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Use // 3. どちらも存在しない → users + user_identities を INSERT して返す func (r *UserRepository) UpsertWithIdentity( ctx context.Context, - email, name, provider, providerSub string, + email, name, provider, providerSub, roleHint string, ) (*domain.User, error) { tx, err := r.db.Begin(ctx) if err != nil { @@ -71,15 +71,19 @@ func (r *UserRepository) UpsertWithIdentity( } if err == nil { - // identity が既存 → ユーザーを返す(name のみ更新) + // identity が既存 → name と roleHint を更新 + // roleHint が空のときは既存ロールを維持する var u domain.User var idStr string if err := tx.QueryRow(ctx, ` - UPDATE users SET name = $2, updated_at = NOW() + UPDATE users + SET name = $2, + role = CASE WHEN $3 != '' THEN $3 ELSE role END, + updated_at = NOW() WHERE id = $1 RETURNING id, email, name, role, created_at, updated_at - `, userIDStr, name).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { - return nil, fmt.Errorf("update user name: %w", err) + `, userIDStr, name, roleHint).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("update user: %w", err) } u.ID, err = uuid.Parse(idStr) if err != nil { @@ -105,10 +109,12 @@ func (r *UserRepository) UpsertWithIdentity( if err != nil { // 3. 新規ユーザー作成 + // roleHint が空のときはデフォルト ('student') をそのまま使う if err := tx.QueryRow(ctx, ` - INSERT INTO users (email, name) VALUES ($1, $2) + INSERT INTO users (email, name, role) + VALUES ($1, $2, CASE WHEN $3 != '' THEN $3 ELSE 'student' END) RETURNING id, email, name, role, created_at, updated_at - `, email, name).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + `, email, name, roleHint).Scan(&idStr, &u.Email, &u.Name, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, fmt.Errorf("insert user: %w", err) } } diff --git a/backend/internal/infrastructure/oidc/verifier.go b/backend/internal/infrastructure/oidc/verifier.go index 2775c6d..b32be7a 100644 --- a/backend/internal/infrastructure/oidc/verifier.go +++ b/backend/internal/infrastructure/oidc/verifier.go @@ -28,11 +28,14 @@ func (v *Verifier) Verify(ctx context.Context, rawIDToken, expectedNonce string) } var c struct { - Sub string `json:"sub"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Name string `json:"name"` - Nonce string `json:"nonce"` + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Nonce string `json:"nonce"` + // groups は authentik などのプロバイダーが発行するグループクレーム。 + // Google など非対応プロバイダーでは nil になる。 + Groups []string `json:"groups"` } if err := idToken.Claims(&c); err != nil { return nil, err @@ -47,5 +50,6 @@ func (v *Verifier) Verify(ctx context.Context, rawIDToken, expectedNonce string) Email: c.Email, EmailVerified: c.EmailVerified, Name: c.Name, + Groups: c.Groups, }, nil } diff --git a/backend/internal/usecase/auth.go b/backend/internal/usecase/auth.go index c429ed4..46e8318 100644 --- a/backend/internal/usecase/auth.go +++ b/backend/internal/usecase/auth.go @@ -13,7 +13,9 @@ import ( // AuthUserRepository はユーザーの永続化操作を定義する type AuthUserRepository interface { - UpsertWithIdentity(ctx context.Context, email, name, provider, providerSub string) (*domain.User, error) + // UpsertWithIdentity は JIT プロビジョニングを行う。 + // roleHint が空文字でない場合、ログイン時にロールを上書きする (SSO グループ同期用)。 + UpsertWithIdentity(ctx context.Context, email, name, provider, providerSub, roleHint string) (*domain.User, error) } // AuthAllowedEmailRepository は個別許可メールアドレスの参照を定義する @@ -27,6 +29,9 @@ type AuthUsecase struct { allowedEmailRepo AuthAllowedEmailRepository allowDomains []string jwtSecret []byte + // adminGroups は admin ロールを付与するグループ名一覧 (authentik の groups claim と照合)。 + // 空のときはグループによるロール同期を行わない。 + adminGroups []string } func NewAuthUsecase( @@ -34,12 +39,14 @@ func NewAuthUsecase( allowedEmailRepo AuthAllowedEmailRepository, allowDomains []string, jwtSecret []byte, + adminGroups []string, ) *AuthUsecase { return &AuthUsecase{ userRepo: userRepo, allowedEmailRepo: allowedEmailRepo, allowDomains: allowDomains, jwtSecret: jwtSecret, + adminGroups: adminGroups, } } @@ -58,9 +65,10 @@ func (u *AuthUsecase) ProcessLogin(ctx context.Context, provider string, claims return "", &AuthError{Code: "access_denied"} } + roleHint := u.resolveRoleHint(claims.Groups) user, err := u.userRepo.UpsertWithIdentity( ctx, - strings.ToLower(claims.Email), claims.Name, provider, claims.Sub, + strings.ToLower(claims.Email), claims.Name, provider, claims.Sub, roleHint, ) if err != nil { return "", fmt.Errorf("provision user: %w", err) @@ -116,6 +124,24 @@ func (u *AuthUsecase) issueAppToken(user *domain.User) (string, error) { return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(u.jwtSecret) } +// resolveRoleHint は OIDC の groups クレームから roleHint を決定する。 +// adminGroups が設定されていて groups 内に一致するものがあれば "admin"、 +// adminGroups が設定されていて一致しなければ "student" を返す。 +// adminGroups 未設定のときは "" を返し、ロールを変更しない。 +func (u *AuthUsecase) resolveRoleHint(groups []string) string { + if len(u.adminGroups) == 0 { + return "" + } + for _, g := range groups { + for _, ag := range u.adminGroups { + if strings.EqualFold(g, ag) { + return "admin" + } + } + } + return "student" +} + // AuthError は認証ビジネスロジックのエラー型 type AuthError struct { Code string // "email_not_verified" | "access_denied" From a8684435544033115a4d8e6e50a55738b010e50e Mon Sep 17 00:00:00 2001 From: mikuto <152457695+Mikuto0831@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:41:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E7=AE=A1=E7=90=86=E8=80=85?= =?UTF-8?q?=E7=99=BB=E9=8C=B2=E6=96=B9=E6=B3=95=E3=81=A8=20AUTH=5FBYPASS?= =?UTF-8?q?=20=E3=81=AE=E3=83=87=E3=83=95=E3=82=A9=E3=83=AB=E3=83=88?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=82=92=20README=20=E3=81=AB=E5=8F=8D?= =?UTF-8?q?=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AUTH_BYPASS=true のセクションを削除し、デフォルト false であることを明記 - 管理者登録方法を 2 つに整理: A. DB 直接 UPDATE(初回・手動セットアップ向け) B. authentik グループによる自動同期(本番環境向け) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 57 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 040d8cd..39f24fb 100644 --- a/README.md +++ b/README.md @@ -40,18 +40,15 @@ cd frontend && pnpm install && cd .. ### 3. 環境変数の設定 -バックエンドは起動時に環境変数を読みます。ローカル開発は `.env` ファイルか shell で設定してください。 - -#### 最小構成(認証バイパスあり) +バックエンドは起動時に環境変数を読みます。`backend/.env.example` をコピーして `backend/.env` を作成し、値を設定してください。 ```bash -# backend/.env または shell に設定 -export AUTH_BYPASS=true # JWT 検証をスキップ(テスト管理者でログイン済み扱い) -export DATABASE_URL="postgresql://daileycoding:daileycoding@localhost:5432/daileycoding?sslmode=disable" -export FRONTEND_ORIGIN="http://localhost:3000" +cp backend/.env.example backend/.env ``` -`AUTH_BYPASS=true` の場合、`/api/auth/me` は常に固定のテスト管理者を返します。OAuth2 設定は不要です。 +> **`AUTH_BYPASS` について** +> `AUTH_BYPASS=true` にすると JWT 検証を完全スキップし、固定のテスト管理者として動作します。 +> **デフォルトは `false`** です。開発時も OIDC プロバイダーを使ってください。 #### Google OAuth2 を使う場合 @@ -87,23 +84,51 @@ mise run db:migrate # マイグレーションを実行(初回・変更時) マイグレーションは `backend/internal/infrastructure/db/migrations/` 以下の SQL ファイルが順番に適用されます。 -### 5. 最初の管理者ユーザーの登録 +### 5. 管理者アカウントの登録 + +ログインすると `role=student` のユーザーが自動作成されます。 +管理者への昇格には **2 つの方法** があります。 -Google / Keycloak でログインすると `role=student` のユーザーが自動作成されます。 -最初の管理者は DB に直接 INSERT するか、既存ユーザーを UPDATE して設定します。 +#### 方法 A: DB を直接更新(手動・初回セットアップ向け) + +まず対象ユーザーが一度ログインしてアカウントを作成してから実行します。 ```bash -# コンテナへ接続 docker compose exec postgres psql -U daileycoding -d daileycoding +``` -# ログイン済みユーザーの role を admin に昇格 +```sql +-- ログイン済みユーザーを管理者に昇格 UPDATE users SET role = 'admin' WHERE email = 'yourname@example.com'; -# まだ一度もログインしていない場合は INSERT(Google ログイン後に UPDATE する方が簡単) -INSERT INTO users (email, name, role) -VALUES ('yourname@example.com', '管理者名', 'admin'); +-- 昇格後は再ログインすると admin ロールが反映される ``` +#### 方法 B: authentik グループで自動同期(推奨・本番環境向け) + +authentik を OIDC プロバイダーとして使う場合、グループ単位で管理者を自動付与できます。 + +**authentik 側の設定:** + +1. authentik 管理画面でグループ(例: `dailycoding-admins`)を作成し、管理者にしたいユーザーを追加する +2. Provider の **Advanced Protocol Settings → Scopes** に `groups` Property Mapping を追加する + (これにより ID Token に `groups` クレームが含まれるようになる) + +**バックエンド側の設定:** + +```bash +# backend/.env.prod に追記 +ADMIN_GROUPS=dailycoding-admins # カンマ区切りで複数指定可 +``` + +この設定を有効にすると、ログイン時にグループが照合されロールが自動で同期されます。 + +| 状態 | 結果 | +|---|---| +| グループに所属している | `role = admin` | +| グループから除外された | 次回ログイン時に `role = student` に戻る | +| `ADMIN_GROUPS` 未設定 | ロールを変更しない(方法 A の手動管理) | + 管理者になると `/admin/problems` の問題管理ページにアクセスできます。 --- From 89329d580198c2f44bd0e15d0ae67ed56a34ffa6 Mon Sep 17 00:00:00 2001 From: mikuto <152457695+Mikuto0831@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:43:02 +0900 Subject: [PATCH 3/3] =?UTF-8?q?revert:=20README=20=E3=81=AE=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E7=99=BB=E9=8C=B2=E3=83=89=E3=82=AD=E3=83=A5?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92=20PR1=20=E3=81=B8=E7=A7=BB?= =?UTF-8?q?=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- README.md | 57 ++++++++++++++++--------------------------------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 39f24fb..040d8cd 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,18 @@ cd frontend && pnpm install && cd .. ### 3. 環境変数の設定 -バックエンドは起動時に環境変数を読みます。`backend/.env.example` をコピーして `backend/.env` を作成し、値を設定してください。 +バックエンドは起動時に環境変数を読みます。ローカル開発は `.env` ファイルか shell で設定してください。 + +#### 最小構成(認証バイパスあり) ```bash -cp backend/.env.example backend/.env +# backend/.env または shell に設定 +export AUTH_BYPASS=true # JWT 検証をスキップ(テスト管理者でログイン済み扱い) +export DATABASE_URL="postgresql://daileycoding:daileycoding@localhost:5432/daileycoding?sslmode=disable" +export FRONTEND_ORIGIN="http://localhost:3000" ``` -> **`AUTH_BYPASS` について** -> `AUTH_BYPASS=true` にすると JWT 検証を完全スキップし、固定のテスト管理者として動作します。 -> **デフォルトは `false`** です。開発時も OIDC プロバイダーを使ってください。 +`AUTH_BYPASS=true` の場合、`/api/auth/me` は常に固定のテスト管理者を返します。OAuth2 設定は不要です。 #### Google OAuth2 を使う場合 @@ -84,51 +87,23 @@ mise run db:migrate # マイグレーションを実行(初回・変更時) マイグレーションは `backend/internal/infrastructure/db/migrations/` 以下の SQL ファイルが順番に適用されます。 -### 5. 管理者アカウントの登録 - -ログインすると `role=student` のユーザーが自動作成されます。 -管理者への昇格には **2 つの方法** があります。 +### 5. 最初の管理者ユーザーの登録 -#### 方法 A: DB を直接更新(手動・初回セットアップ向け) - -まず対象ユーザーが一度ログインしてアカウントを作成してから実行します。 +Google / Keycloak でログインすると `role=student` のユーザーが自動作成されます。 +最初の管理者は DB に直接 INSERT するか、既存ユーザーを UPDATE して設定します。 ```bash +# コンテナへ接続 docker compose exec postgres psql -U daileycoding -d daileycoding -``` -```sql --- ログイン済みユーザーを管理者に昇格 +# ログイン済みユーザーの role を admin に昇格 UPDATE users SET role = 'admin' WHERE email = 'yourname@example.com'; --- 昇格後は再ログインすると admin ロールが反映される +# まだ一度もログインしていない場合は INSERT(Google ログイン後に UPDATE する方が簡単) +INSERT INTO users (email, name, role) +VALUES ('yourname@example.com', '管理者名', 'admin'); ``` -#### 方法 B: authentik グループで自動同期(推奨・本番環境向け) - -authentik を OIDC プロバイダーとして使う場合、グループ単位で管理者を自動付与できます。 - -**authentik 側の設定:** - -1. authentik 管理画面でグループ(例: `dailycoding-admins`)を作成し、管理者にしたいユーザーを追加する -2. Provider の **Advanced Protocol Settings → Scopes** に `groups` Property Mapping を追加する - (これにより ID Token に `groups` クレームが含まれるようになる) - -**バックエンド側の設定:** - -```bash -# backend/.env.prod に追記 -ADMIN_GROUPS=dailycoding-admins # カンマ区切りで複数指定可 -``` - -この設定を有効にすると、ログイン時にグループが照合されロールが自動で同期されます。 - -| 状態 | 結果 | -|---|---| -| グループに所属している | `role = admin` | -| グループから除外された | 次回ログイン時に `role = student` に戻る | -| `ADMIN_GROUPS` 未設定 | ロールを変更しない(方法 A の手動管理) | - 管理者になると `/admin/problems` の問題管理ページにアクセスできます。 ---