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

@@ -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: <TenantDetailPage /> },
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "roles", element: <RoleListPage /> },
{ path: "roles/new", element: <RoleCreatePage /> },
{ path: "roles/:id", element: <RoleDetailPage /> },
],
},
],

View File

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

View File

@@ -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<string | null>(null);
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["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 (
<div className="max-w-xl mx-auto py-12 space-y-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">API </h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
</p>
</div>
<Card className="border-2 border-primary/20 shadow-xl">
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
릿
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
<div className="space-y-3">
<Label className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
<Copy size={20} className="text-primary" />
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
( ) .
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
<header className="space-y-2">
<Link
to="/api-keys"
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
>
<ChevronLeft size={14} />
Back to list
</Link>
<h2 className="text-3xl font-semibold"> API </h2>
<p className="text-sm text-[var(--color-muted)]">
Machine-to-Machine API .
</p>
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
<ChevronLeft size={16} className="mr-1" />
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
</div>
</header>
<div className="rounded-lg border border-dashed p-12 text-center">
<p className="text-[var(--color-muted)]">
API .
</p>
<Button className="mt-4" disabled>
( )
</Button>
<div className="space-y-8">
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
<h3 className="font-semibold text-lg"> </h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
className="text-base py-5"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
</div>
</CardContent>
</Card>
</section>
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
<h3 className="font-semibold text-lg"> (Scopes) </h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
const isSelected = selectedScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30"
)}
>
<div className="flex items-center justify-between w-full">
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
<div className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{scope.desc}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
</button>
);
})}
</div>
</section>
{/* 하단 실행 버튼 */}
<div className="pt-6 flex flex-col gap-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg flex items-center gap-3">
<AlertCircle size={20} />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
API
</Button>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyCreatePage;
export default ApiKeyCreatePage;

View File

@@ -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<ApiKeyListResponse>(
"/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<RoleListResponse>("/v1/admin/roles", {
params: { limit, offset },
});
export async function createApiKey(payload: ApiKeyCreateRequest) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
"/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

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
}