+
-
-
- API 키 생성 폼이 여기에 구현될 예정입니다.
-
-
+
+ {/* 섹션 1: 이름 설정 */}
+
+
+ 1
+
키 이름 지정
+
+
+
+
+
+
+ {errors.name &&
{errors.name.message}
}
+
+
+
+
+
+ {/* 섹션 2: 권한 선택 */}
+
+
+ 2
+
권한 범위(Scopes) 선택
+
+
+ {AVAILABLE_SCOPES.map((scope) => {
+ const isSelected = selectedScopes.includes(scope.id);
+ return (
+
+ );
+ })}
+
+
+
+ {/* 하단 실행 버튼 */}
+
+ {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
+}