1
0
forked from baron/baron-sso

api키 생성 기능 및 페이지 구현

This commit is contained in:
2026-01-29 16:38:27 +09:00
parent ee4c07f66d
commit a27026fa2a
9 changed files with 546 additions and 44 deletions

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}