forked from baron/baron-sso
api키 생성 기능 및 페이지 구현
This commit is contained in:
@@ -6,9 +6,6 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
|
|||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
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 TenantCreatePage from "../features/tenants/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/TenantListPage";
|
import TenantListPage from "../features/tenants/TenantListPage";
|
||||||
@@ -34,9 +31,6 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
{ path: "tenants/:id", element: <TenantDetailPage /> },
|
||||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||||
{ path: "roles", element: <RoleListPage /> },
|
|
||||||
{ path: "roles/new", element: <RoleCreatePage /> },
|
|
||||||
{ path: "roles/:id", element: <RoleDetailPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
Shield,
|
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
@@ -19,7 +18,6 @@ const navItems = [
|
|||||||
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
|
||||||
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
{ label: "Tenants", to: "/tenants", icon: Building2 },
|
||||||
{ label: "Users", to: "/users", icon: Users },
|
{ label: "Users", to: "/users", icon: Users },
|
||||||
{ label: "Roles & RBAC", to: "/roles", icon: Shield },
|
|
||||||
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
|
||||||
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
|
||||||
|
|||||||
@@ -1,34 +1,241 @@
|
|||||||
import { ChevronLeft } from "lucide-react";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router-dom";
|
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 { 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="max-w-3xl mx-auto space-y-10">
|
||||||
<header className="space-y-2">
|
<header className="flex items-center justify-between">
|
||||||
<Link
|
<div className="space-y-1">
|
||||||
to="/api-keys"
|
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
|
||||||
className="flex items-center gap-1 text-sm text-[var(--color-muted)] hover:text-foreground"
|
<ChevronLeft size={16} className="mr-1" />
|
||||||
>
|
돌아가기
|
||||||
<ChevronLeft size={14} />
|
</Button>
|
||||||
Back to list
|
<h2 className="text-3xl font-bold tracking-tight">새 API 키 생성</h2>
|
||||||
</Link>
|
<p className="text-muted-foreground">내부 시스템 연동을 위한 보안 인증 키를 구성합니다.</p>
|
||||||
<h2 className="text-3xl font-semibold">새 API 키 생성</h2>
|
</div>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
|
||||||
새로운 Machine-to-Machine 통신용 API 키를 설정합니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center">
|
<div className="space-y-8">
|
||||||
<p className="text-[var(--color-muted)]">
|
{/* 섹션 1: 이름 설정 */}
|
||||||
API 키 생성 폼이 여기에 구현될 예정입니다.
|
<section className="space-y-4">
|
||||||
</p>
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
<Button className="mt-4" disabled>
|
<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>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ApiKeyCreatePage;
|
export default ApiKeyCreatePage;
|
||||||
@@ -127,8 +127,17 @@ export async function deleteTenant(tenantId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API Key Management (M2M)
|
// 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) {
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
// Placeholder implementation
|
|
||||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
"/v1/admin/api-keys",
|
"/v1/admin/api-keys",
|
||||||
{
|
{
|
||||||
@@ -138,21 +147,16 @@ export async function fetchApiKeys(limit = 50, offset = 0) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApiKey(apiKeyId: string) {
|
export async function createApiKey(payload: ApiKeyCreateRequest) {
|
||||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
const { data } = await apiClient.post<ApiKeyCreateResponse>(
|
||||||
}
|
"/v1/admin/api-keys",
|
||||||
|
payload,
|
||||||
// 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 },
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(roleId: string) {
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
await apiClient.delete(`/v1/admin/roles/${roleId}`);
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// User Management
|
// User Management
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func main() {
|
|||||||
devHandler := handler.NewDevHandler()
|
devHandler := handler.NewDevHandler()
|
||||||
tenantHandler := handler.NewTenantHandler(db)
|
tenantHandler := handler.NewTenantHandler(db)
|
||||||
userHandler := handler.NewUserHandler(db)
|
userHandler := handler.NewUserHandler(db)
|
||||||
|
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
appEnv := getEnv("APP_ENV", "dev")
|
appEnv := getEnv("APP_ENV", "dev")
|
||||||
@@ -385,6 +386,7 @@ func main() {
|
|||||||
|
|
||||||
// Admin Routes
|
// Admin Routes
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
|
||||||
admin.Get("/check", adminHandler.CheckAuth)
|
admin.Get("/check", adminHandler.CheckAuth)
|
||||||
admin.Get("/tenants", tenantHandler.ListTenants)
|
admin.Get("/tenants", tenantHandler.ListTenants)
|
||||||
admin.Post("/tenants", tenantHandler.CreateTenant)
|
admin.Post("/tenants", tenantHandler.CreateTenant)
|
||||||
@@ -399,6 +401,11 @@ func main() {
|
|||||||
admin.Put("/users/:id", userHandler.UpdateUser)
|
admin.Put("/users/:id", userHandler.UpdateUser)
|
||||||
admin.Delete("/users/:id", userHandler.DeleteUser)
|
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 관리)
|
// 개발자 포털 라우트 (RP/Consent 관리)
|
||||||
dev := api.Group("/dev")
|
dev := api.Group("/dev")
|
||||||
dev.Get("/clients", devHandler.ListClients)
|
dev.Get("/clients", devHandler.ListClients)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
&domain.User{},
|
&domain.User{},
|
||||||
&domain.Tenant{},
|
&domain.Tenant{},
|
||||||
|
&domain.ApiKey{},
|
||||||
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
|
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
|
||||||
// &domain.UserConsent{}, // 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