forked from baron/baron-sso
api키 생성 기능 및 페이지 구현
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
30
backend/internal/domain/api_key.go
Normal file
30
backend/internal/domain/api_key.go
Normal 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
|
||||
}
|
||||
145
backend/internal/handler/api_key_handler.go
Normal file
145
backend/internal/handler/api_key_handler.go
Normal 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)
|
||||
}
|
||||
116
backend/internal/middleware/api_key_auth.go
Normal file
116
backend/internal/middleware/api_key_auth.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user