forked from baron/baron-sso
289 lines
7.8 KiB
Go
289 lines
7.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"baron-sso-backend/internal/pagination"
|
|
"errors"
|
|
"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"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
Cursor string `json:"cursor,omitempty"`
|
|
NextCursor string `json:"nextCursor,omitempty"`
|
|
}
|
|
|
|
func apiKeyToSummary(k domain.ApiKey) apiKeySummary {
|
|
lastUsed := ""
|
|
if k.LastUsedAt != nil {
|
|
lastUsed = k.LastUsedAt.Format(time.RFC3339)
|
|
}
|
|
return 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,
|
|
}
|
|
}
|
|
|
|
func apiKeyWithUpdatedScopes(k domain.ApiKey, scopes []string) domain.ApiKey {
|
|
k.Scopes = strings.Join(normalizeApiKeyScopes(scopes), " ")
|
|
return k
|
|
}
|
|
|
|
func apiKeyWithRotatedSecretHash(k domain.ApiKey, hashedSecret string) domain.ApiKey {
|
|
k.ClientSecretHash = hashedSecret
|
|
return k
|
|
}
|
|
|
|
func normalizeApiKeyScopes(scopes []string) []string {
|
|
seen := make(map[string]struct{}, len(scopes))
|
|
normalized := make([]string, 0, len(scopes))
|
|
for _, scope := range scopes {
|
|
scope = strings.TrimSpace(scope)
|
|
if scope == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[scope]; exists {
|
|
continue
|
|
}
|
|
seen[scope] = struct{}{}
|
|
normalized = append(normalized, scope)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
|
|
if h.DB == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
|
}
|
|
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
var total int64
|
|
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
var keys []domain.ApiKey
|
|
query := h.DB.Order("created_at desc, id desc").Limit(limit + 1)
|
|
if cursorRaw != "" {
|
|
cursor, err := pagination.Decode(cursorRaw)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
|
|
}
|
|
query = pagination.ApplyCreatedAtIDCursor(query, cursor, "created_at", "id")
|
|
offset = 0
|
|
} else {
|
|
query = query.Offset(offset)
|
|
}
|
|
|
|
if err := query.Find(&keys).Error; err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
nextCursor := ""
|
|
hasMore := len(keys) > limit
|
|
if len(keys) > limit {
|
|
keys = keys[:limit]
|
|
}
|
|
if cursorRaw == "" && total > int64(offset+len(keys)) {
|
|
hasMore = true
|
|
}
|
|
if hasMore && len(keys) > 0 {
|
|
last := keys[len(keys)-1]
|
|
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
|
}
|
|
|
|
items := make([]apiKeySummary, 0, len(keys))
|
|
for _, k := range keys {
|
|
items = append(items, apiKeyToSummary(k))
|
|
}
|
|
|
|
return c.JSON(apiKeyListResponse{
|
|
Items: items,
|
|
Total: total,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
Cursor: cursorRaw,
|
|
NextCursor: nextCursor,
|
|
})
|
|
}
|
|
|
|
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
|
if h.DB == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
|
}
|
|
req.Scopes = normalizeApiKeyScopes(req.Scopes)
|
|
if len(req.Scopes) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "at least one scope 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 errorJSON(c, fiber.StatusInternalServerError, "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 errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
// Return summary + PLAIN SECRET (only this time)
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"apiKey": apiKeyToSummary(apiKey),
|
|
"clientSecret": plainSecret, // VERY IMPORTANT: user must save this now
|
|
})
|
|
}
|
|
|
|
func (h *ApiKeyHandler) UpdateApiKey(c *fiber.Ctx) error {
|
|
if h.DB == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
|
}
|
|
|
|
id := c.Params("id")
|
|
if id == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
|
}
|
|
|
|
var req struct {
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
req.Scopes = normalizeApiKeyScopes(req.Scopes)
|
|
if len(req.Scopes) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
|
|
}
|
|
|
|
var apiKey domain.ApiKey
|
|
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errorJSON(c, fiber.StatusNotFound, "api key not found")
|
|
}
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
apiKey = apiKeyWithUpdatedScopes(apiKey, req.Scopes)
|
|
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("scopes", apiKey.Scopes).Error; err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.JSON(apiKeyToSummary(apiKey))
|
|
}
|
|
|
|
func (h *ApiKeyHandler) RotateApiKeySecret(c *fiber.Ctx) error {
|
|
if h.DB == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
|
}
|
|
|
|
id := c.Params("id")
|
|
if id == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
|
}
|
|
|
|
var apiKey domain.ApiKey
|
|
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errorJSON(c, fiber.StatusNotFound, "api key not found")
|
|
}
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
plainSecret := GenerateSecureToken(8)
|
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
|
|
}
|
|
|
|
apiKey = apiKeyWithRotatedSecretHash(apiKey, string(hashedSecret))
|
|
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("client_secret_hash", apiKey.ClientSecretHash).Error; err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"apiKey": apiKeyToSummary(apiKey),
|
|
"clientSecret": plainSecret,
|
|
})
|
|
}
|
|
|
|
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
|
|
if h.DB == nil {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
|
}
|
|
|
|
id := c.Params("id")
|
|
if id == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "id is required")
|
|
}
|
|
|
|
if err := h.DB.Delete(&domain.ApiKey{}, "id = ?", id).Error; err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.SendStatus(fiber.StatusNoContent)
|
|
}
|