diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 6100af70..262320a1 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,9 +6,6 @@ import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthPage from "../features/auth/AuthPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; -import RoleCreatePage from "../features/roles/RoleCreatePage"; -import RoleDetailPage from "../features/roles/RoleDetailPage"; -import RoleListPage from "../features/roles/RoleListPage"; import TenantCreatePage from "../features/tenants/TenantCreatePage"; import TenantDetailPage from "../features/tenants/TenantDetailPage"; import TenantListPage from "../features/tenants/TenantListPage"; @@ -34,9 +31,6 @@ export const router = createBrowserRouter( { path: "tenants/:id", element: }, { path: "api-keys", element: }, { path: "api-keys/new", element: }, - { path: "roles", element: }, - { path: "roles/new", element: }, - { path: "roles/:id", element: }, ], }, ], diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 0c94d0ad..c7e651a7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -6,7 +6,6 @@ import { LayoutDashboard, Moon, NotebookTabs, - Shield, ShieldHalf, Sun, Users, @@ -19,7 +18,6 @@ const navItems = [ { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, { label: "Tenants", to: "/tenants", icon: Building2 }, { label: "Users", to: "/users", icon: Users }, - { label: "Roles & RBAC", to: "/roles", icon: Shield }, { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, { label: "Auth Guard", to: "/auth", icon: KeyRound }, diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx index 81a0c072..09280945 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -1,34 +1,241 @@ -import { ChevronLeft } from "lucide-react"; -import { Link } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate } from "react-router-dom"; +import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { cn } from "../../lib/utils"; +import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi"; + +const AVAILABLE_SCOPES = [ + { id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." }, + { id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." }, + { id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." }, + { id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." }, + { id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." }, + { id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." }, +]; function ApiKeyCreatePage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [error, setError] = React.useState(null); + const [createdResult, setCreatedResult] = React.useState(null); + const [selectedScopes, setSelectedScopes] = React.useState(["audit:read", "user:read"]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<{ name: string }>({ + defaultValues: { name: "" }, + }); + + const mutation = useMutation({ + mutationFn: (payload: ApiKeyCreateRequest) => createApiKey(payload), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }); + setCreatedResult(data); + }, + onError: (err: AxiosError<{ error?: string }>) => { + setError(err.response?.data?.error || "API 키 생성에 실패했습니다."); + }, + }); + + const toggleScope = (scopeId: string) => { + setSelectedScopes((prev) => + prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId] + ); + }; + + const onSubmit = (data: { name: string }) => { + if (selectedScopes.length === 0) { + setError("최소 하나 이상의 권한을 선택해야 합니다."); + return; + } + setError(null); + mutation.mutate({ name: data.name, scopes: selectedScopes }); + }; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + }; + + if (createdResult) { + return ( +
+
+
+ +
+

API 키 생성 완료

+

+ 아래의 비밀번호(Secret)는 보안을 위해 지금 한 번만 표시됩니다. +

+
+ + + + + + 보안 시크릿 복사 + + + +
+ +
+ + +
+

+ 복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요. +

+
+ +
+ +
+
+
+
+ ); + } + return ( -
-
- - - Back to list - -

새 API 키 생성

-

- 새로운 Machine-to-Machine 통신용 API 키를 설정합니다. -

+
+
+
+ +

새 API 키 생성

+

내부 시스템 연동을 위한 보안 인증 키를 구성합니다.

+
-
-

- API 키 생성 폼이 여기에 구현될 예정입니다. -

- +
+ {/* 섹션 1: 이름 설정 */} +
+
+ 1 +

키 이름 지정

+
+ + +
+ + + {errors.name &&

{errors.name.message}

} +
+
+
+
+ + {/* 섹션 2: 권한 선택 */} +
+
+ 2 +

권한 범위(Scopes) 선택

+
+
+ {AVAILABLE_SCOPES.map((scope) => { + const isSelected = selectedScopes.includes(scope.id); + return ( + + ); + })} +
+
+ + {/* 하단 실행 버튼 */} +
+ {error && ( +
+ +

{error}

+
+ )} + +
+
+

총 {selectedScopes.length}개의 권한이 할당됩니다.

+

생성 즉시 활성화되어 사용 가능합니다.

+
+ +
+
); } -export default ApiKeyCreatePage; +export default ApiKeyCreatePage; \ No newline at end of file diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 9ea88895..965d5092 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -127,8 +127,17 @@ export async function deleteTenant(tenantId: string) { } // API Key Management (M2M) +export type ApiKeyCreateRequest = { + name: string; + scopes: string[]; +}; + +export type ApiKeyCreateResponse = { + apiKey: ApiKeySummary; + clientSecret: string; +}; + export async function fetchApiKeys(limit = 50, offset = 0) { - // Placeholder implementation const { data } = await apiClient.get( "/v1/admin/api-keys", { @@ -138,21 +147,16 @@ export async function fetchApiKeys(limit = 50, offset = 0) { return data; } -export async function deleteApiKey(apiKeyId: string) { - await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`); -} - -// Role Management (RBAC) -export async function fetchRoles(limit = 50, offset = 0) { - // Placeholder implementation - const { data } = await apiClient.get("/v1/admin/roles", { - params: { limit, offset }, - }); +export async function createApiKey(payload: ApiKeyCreateRequest) { + const { data } = await apiClient.post( + "/v1/admin/api-keys", + payload, + ); return data; } -export async function deleteRole(roleId: string) { - await apiClient.delete(`/v1/admin/roles/${roleId}`); +export async function deleteApiKey(apiKeyId: string) { + await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`); } // User Management diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c9f97421..999fadc1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -165,6 +165,7 @@ func main() { devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) userHandler := handler.NewUserHandler(db) + apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber appEnv := getEnv("APP_ENV", "dev") @@ -385,6 +386,7 @@ func main() { // Admin Routes admin := api.Group("/admin") + admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가 admin.Get("/check", adminHandler.CheckAuth) admin.Get("/tenants", tenantHandler.ListTenants) admin.Post("/tenants", tenantHandler.CreateTenant) @@ -399,6 +401,11 @@ func main() { admin.Put("/users/:id", userHandler.UpdateUser) admin.Delete("/users/:id", userHandler.DeleteUser) + // API Key Management (M2M) + admin.Get("/api-keys", apiKeyHandler.ListApiKeys) + admin.Post("/api-keys", apiKeyHandler.CreateApiKey) + admin.Delete("/api-keys/:id", apiKeyHandler.DeleteApiKey) + // 개발자 포털 라우트 (RP/Consent 관리) dev := api.Group("/dev") dev.Get("/clients", devHandler.ListClients) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 8171d0db..c6f6993a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -35,6 +35,7 @@ func migrateSchemas(db *gorm.DB) error { return db.AutoMigrate( &domain.User{}, &domain.Tenant{}, + &domain.ApiKey{}, // &domain.RelyingParty{}, // TODO: Uncomment when model is ready // &domain.UserConsent{}, // TODO: Uncomment when model is ready ) diff --git a/backend/internal/domain/api_key.go b/backend/internal/domain/api_key.go new file mode 100644 index 00000000..dc4d86fa --- /dev/null +++ b/backend/internal/domain/api_key.go @@ -0,0 +1,30 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ApiKey represents an internal API key for Machine-to-Machine communication. +type ApiKey struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + ClientID string `gorm:"uniqueIndex;not null" json:"clientId"` + ClientSecretHash string `gorm:"not null" json:"-"` + Scopes string `json:"scopes"` // Space or comma separated + Status string `gorm:"default:'active'" json:"status"` + LastUsedAt *time.Time `json:"lastUsedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// BeforeCreate hook to generate UUID if not present. +func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) { + if k.ID == "" { + k.ID = uuid.NewString() + } + return +} diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go new file mode 100644 index 00000000..bb1d8afb --- /dev/null +++ b/backend/internal/handler/api_key_handler.go @@ -0,0 +1,145 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ApiKeyHandler struct { + DB *gorm.DB +} + +func NewApiKeyHandler(db *gorm.DB) *ApiKeyHandler { + return &ApiKeyHandler{DB: db} +} + +type apiKeySummary struct { + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Scopes []string `json:"scopes"` + Status string `json:"status"` + LastUsedAt *string `json:"lastUsedAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type apiKeyListResponse struct { + Items []apiKeySummary `json:"items"` + Total int64 `json:"total"` +} + +func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + limit := c.QueryInt("limit", 50) + offset := c.QueryInt("offset", 0) + + var total int64 + if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + var keys []domain.ApiKey + if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + items := make([]apiKeySummary, 0, len(keys)) + for _, k := range keys { + lastUsed := "" + if k.LastUsedAt != nil { + lastUsed = k.LastUsedAt.Format(time.RFC3339) + } + items = append(items, apiKeySummary{ + ID: k.ID, + Name: k.Name, + ClientID: k.ClientID, + Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")), + Status: k.Status, + LastUsedAt: &lastUsed, + CreatedAt: k.CreatedAt, + }) + } + + return c.JSON(apiKeyListResponse{Items: items, Total: total}) +} + +func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + var req struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + if strings.TrimSpace(req.Name) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + + // Generate Client ID (16 chars hex) + clientID := GenerateSecureToken(8) + + // Generate plain secret (16 chars hex) + plainSecret := GenerateSecureToken(8) + + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash secret"}) + } + + apiKey := domain.ApiKey{ + Name: req.Name, + ClientID: clientID, + ClientSecretHash: string(hashedSecret), + Scopes: strings.Join(req.Scopes, " "), + Status: "active", + } + + if err := h.DB.Create(&apiKey).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + // Return summary + PLAIN SECRET (only this time) + lastUsed := "" + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "apiKey": apiKeySummary{ + ID: apiKey.ID, + Name: apiKey.Name, + ClientID: apiKey.ClientID, + Scopes: req.Scopes, + Status: apiKey.Status, + LastUsedAt: &lastUsed, + CreatedAt: apiKey.CreatedAt, + }, + "clientSecret": plainSecret, // VERY IMPORTANT: user must save this now + }) +} + +func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error { + if h.DB == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + } + + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id is required"}) + } + + if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go new file mode 100644 index 00000000..a5aa8b86 --- /dev/null +++ b/backend/internal/middleware/api_key_auth.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "log/slog" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ApiKeyAuthConfig struct { + DB *gorm.DB +} + +func ApiKeyAuth(config ApiKeyAuthConfig) fiber.Handler { + return func(c *fiber.Ctx) error { + // 1. 헤더에서 ID와 Secret 추출 + clientID := c.Get("X-Baron-Key-ID") + plainSecret := c.Get("X-Baron-Key-Secret") + + // 헤더가 둘 다 없으면 API Key 인증 시도가 아닌 것으로 간주하고 다음으로 넘김 (UI 세션 등을 위해) + if clientID == "" && plainSecret == "" { + return c.Next() + } + + if clientID == "" || plainSecret == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "API Key ID or Secret is missing", + }) + } + + // 2. DB에서 ClientID로 키 정보 조회 + var apiKey domain.ApiKey + if err := config.DB.Where("client_id = ? AND status = ?", clientID, "active").First(&apiKey).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid or inactive API Key", + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database error during authentication", + }) + } + + // 3. Secret 해시 검증 + if err := bcrypt.CompareHashAndPassword([]byte(apiKey.ClientSecretHash), []byte(plainSecret)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Invalid API Secret", + }) + } + + // 4. (비동기) 마지막 사용 시간 업데이트 + go func(id string) { + now := time.Now() + config.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("last_used_at", &now) + }(apiKey.ID) + + // 5. 컨텍스트에 권한 정보 저장 + c.Locals("apiKeyName", apiKey.Name) + c.Locals("apiScopes", apiKey.Scopes) + + // 6. Scope 기반 권한 검증 (RBAC) + if !validateScope(c.Method(), c.Path(), apiKey.Scopes) { + slog.Warn("API Key scope insufficient", "name", apiKey.Name, "method", c.Method(), "path", c.Path(), "has_scopes", apiKey.Scopes) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Insufficient permissions (Scope mismatch)", + }) + } + + slog.Debug("API Key authenticated and authorized", "name", apiKey.Name, "path", c.Path()) + + return c.Next() + } +} + +// validateScope - 요청된 메서드와 경로가 허용된 Scopes에 포함되는지 검사합니다. +func validateScope(method, path string, rawScopes string) bool { + scopes := strings.Fields(rawScopes) + scopeMap := make(map[string]bool) + for _, s := range scopes { + scopeMap[s] = true + } + + // 1. 감사 로그 관련 (audit:*) + if strings.Contains(path, "/admin/audit") || strings.Contains(path, "/v1/audit") { + if method == fiber.MethodGet { + return scopeMap["audit:read"] + } + return scopeMap["audit:write"] + } + + // 2. 사용자 관리 관련 (user:*) + if strings.Contains(path, "/admin/users") { + if method == fiber.MethodGet { + return scopeMap["user:read"] + } + return scopeMap["user:write"] + } + + // 3. 테넌트 관리 관련 (tenant:*) + if strings.Contains(path, "/admin/tenants") { + if method == fiber.MethodGet { + return scopeMap["tenant:read"] + } + return scopeMap["tenant:write"] + } + + // 4. API 체크 등 공통 (기본 허용) + if strings.HasSuffix(path, "/admin/check") { + return true + } + + return false +}