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"