1
0
forked from baron/baron-sso

Merge pull request 'dev/adminpage' (#125) from dev/adminpage into main

Reviewed-on: ai-team/baron-sso#125
This commit is contained in:
2026-01-29 16:46:37 +09:00
21 changed files with 2063 additions and 14 deletions

View File

@@ -1,5 +1,7 @@
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
@@ -7,6 +9,9 @@ import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantCreatePage from "../features/tenants/TenantCreatePage";
import TenantDetailPage from "../features/tenants/TenantDetailPage";
import TenantListPage from "../features/tenants/TenantListPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
export const router = createBrowserRouter(
[
@@ -18,9 +23,14 @@ export const router = createBrowserRouter(
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenants/:id", element: <TenantDetailPage /> },
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],

View File

@@ -1,12 +1,14 @@
import {
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
@@ -15,6 +17,8 @@ const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Users", to: "/users", icon: Users },
{ 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

@@ -0,0 +1,241 @@
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="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="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;

View File

@@ -0,0 +1,172 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
function ApiKeyListPage() {
const query = useQuery({
queryKey: ["api-keys", { limit: 50, offset: 0 }],
queryFn: () => fetchApiKeys(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteApiKey(id),
onSuccess: () => {
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
return;
}
deleteMutation.mutate(id);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>API Keys</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold">API (M2M)</h2>
<p className="text-sm text-[var(--color-muted)]">
(Machine-to-Machine) API
.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
API
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>API Key Registry</CardTitle>
<CardDescription>
{query.data?.total ?? 0} API
</CardDescription>
</div>
<Badge variant="muted">System</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>SCOPES</TableHead>
<TableHead>LAST USED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}> API .</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key size={14} className="text-[var(--color-muted)]" />
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: "Never"}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default ApiKeyListPage;

View File

@@ -559,4 +559,4 @@ function AuditLogsPage() {
);
}
export default AuditLogsPage;
export default AuditLogsPage;

View File

@@ -0,0 +1,206 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
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 { createUser, type UserCreateRequest } from "../../lib/adminApi";
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserCreateRequest>({
defaultValues: {
email: "",
password: "",
name: "",
phone: "",
role: "user",
companyCode: "",
department: "",
},
});
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
},
});
const onSubmit = (data: UserCreateRequest) => {
setError(null);
mutation.mutate(data);
};
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">New</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="********"
{...register("password", {
required: "비밀번호는 필수입니다.",
minLength: { value: 6, message: "6자 이상 입력해주세요." },
})}
/>
<p className="text-xs text-muted-foreground">
.
</p>
{errors.password && (
<p className="text-xs text-destructive">{errors.password.message}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> </Label>
<Input
id="companyCode"
placeholder="HMAC"
{...register("companyCode")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserCreatePage;

View File

@@ -0,0 +1,251 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
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 { fetchUser, updateUser, type UserUpdateRequest } from "../../lib/adminApi";
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
});
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<UserUpdateRequest>({
defaultValues: {
name: "",
phone: "",
role: "user",
status: "active",
companyCode: "",
department: "",
password: "",
},
});
React.useEffect(() => {
if (user) {
reset({
name: user.name,
phone: user.phone || "",
role: user.role,
status: user.status,
companyCode: user.companyCode || "",
department: user.department || "",
password: "",
});
}
}, [user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
setSuccessMsg("사용자 정보가 수정되었습니다.");
setError(null);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
setSuccessMsg(null);
},
});
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
if (!payload.password) {
delete payload.password;
}
mutation.mutate(payload);
};
if (isLoading) {
return <div className="p-8 text-center">Loading...</div>;
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
</div>
);
}
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{user.email} .
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
{successMsg && (
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
{successMsg}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<div className="relative">
<select
id="status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> </Label>
<Input
id="companyCode"
placeholder="HMAC"
{...register("companyCode")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserDetailPage;

View File

@@ -0,0 +1,270 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronLeft,
ChevronRight,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const limit = 50;
const offset = (page - 1) * limit;
const query = useQuery({
queryKey: ["users", { limit, offset, search }],
queryFn: () => fetchUsers(limit, offset, search),
placeholderData: (previousData) => previousData,
});
const deleteMutation = useMutation({
mutationFn: (userId: string) => deleteUser(userId),
onSuccess: () => {
query.refetch();
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
return;
}
deleteMutation.mutate(userId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardDescription>
{total} .
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
</Button>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>COMPANY / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span>{user.companyCode || "-"}</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default UserListPage;

View File

@@ -50,6 +50,35 @@ export type TenantUpdateRequest = {
status?: string;
};
export type ApiKeySummary = {
id: string;
name: string;
client_id: string;
scopes: string[];
status: string;
lastUsedAt?: string;
createdAt: string;
};
export type ApiKeyListResponse = {
items: ApiKeySummary[];
total: number;
};
export type RoleSummary = {
id: string;
name: string;
description: string;
permissions: string[];
createdAt: string;
updatedAt: string;
};
export type RoleListResponse = {
items: RoleSummary[];
total: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -96,3 +125,111 @@ export async function updateTenant(
export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
// 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) {
const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys",
{
params: { limit, offset },
},
);
return data;
}
export async function createApiKey(payload: ApiKeyCreateRequest) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
"/v1/admin/api-keys",
payload,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
}
// User Management
export type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
department?: string;
createdAt: string;
updatedAt: string;
};
export type UserListResponse = {
items: UserSummary[];
limit: number;
offset: number;
total: number;
};
export type UserCreateRequest = {
email: string;
password: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
export type UserUpdateRequest = {
password?: string;
name?: string;
phone?: string;
role?: string;
status?: string;
companyCode?: string;
department?: string;
};
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search },
});
return data;
}
export async function fetchUser(userId: string) {
const { data } = await apiClient.get<UserSummary>(
`/v1/admin/users/${userId}`,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserSummary>(
"/v1/admin/users",
payload,
);
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
payload,
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}

View File

@@ -104,13 +104,17 @@ func main() {
// ClickHouse
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
chUser := getEnv("CLICKHOUSE_USER", "default")
chPass := getEnv("CLICKHOUSE_PASSWORD", "")
chDB := getEnv("CLICKHOUSE_DB", "default")
chUser := getEnv("CLICKHOUSE_USER", "baron")
chPass := getEnv("CLICKHOUSE_PASSWORD", "password")
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
auditRepo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB)
if err != nil {
var auditRepo domain.AuditRepository
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
auditRepo = nil // Explicitly set to nil interface
} else {
auditRepo = repo
slog.Info("✅ Connected to ClickHouse")
}
// PostgreSQL (Meta Store)
@@ -138,11 +142,7 @@ func main() {
})
if err != nil {
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
// For local dev without Postgres, we might want to continue or panic.
// But bootstrap requires DB.
if getEnv("APP_ENV", "dev") == "production" {
os.Exit(1)
}
os.Exit(1)
} else {
slog.Info("✅ Connected to PostgreSQL")
@@ -186,6 +186,8 @@ func main() {
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler()
tenantHandler := handler.NewTenantHandler(db)
userHandler := handler.NewUserHandler(db)
apiKeyHandler := handler.NewApiKeyHandler(db)
// 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev")
@@ -266,7 +268,10 @@ func main() {
return err
})
app.Use(recover.New())
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
}))
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
allowCredentials := allowedOrigins != "*"
app.Use(cors.New(cors.Config{
@@ -448,6 +453,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("/stats", adminHandler.GetSystemStats)
admin.Get("/tenants", tenantHandler.ListTenants)
@@ -456,6 +462,18 @@ func main() {
admin.Put("/tenants/:id", tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", tenantHandler.DeleteTenant)
// Admin User Management
admin.Get("/users", userHandler.ListUsers)
admin.Post("/users", userHandler.CreateUser)
admin.Get("/users/:id", userHandler.GetUser)
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
)
@@ -50,7 +51,7 @@ func seedAdminUser(db *gorm.DB) error {
}
var user domain.User
if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil {
if err := db.Unscoped().Where("email = ?", adminEmail).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)

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

@@ -42,6 +42,12 @@ func (h *AuditHandler) CreateLog(c *fiber.Ctx) error {
req.EventID = ensureRequestID(c)
}
if h.repo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "Audit service unavailable",
})
}
if err := h.repo.Create(&req); err != nil {
// Log internal error but don't expose details
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -65,6 +71,12 @@ func (h *AuditHandler) ListLogs(c *fiber.Ctx) error {
})
}
if h.repo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"error": "Audit service unavailable",
})
}
logs, err := h.repo.FindPage(c.Context(), limit+1, cursor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{

View File

@@ -0,0 +1,267 @@
package handler
import (
"baron-sso-backend/internal/domain"
"errors"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserHandler struct {
DB *gorm.DB
}
func NewUserHandler(db *gorm.DB) *UserHandler {
return &UserHandler{DB: db}
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type userListResponse struct {
Items []userSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
}
func (h *UserHandler) ListUsers(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)
search := strings.TrimSpace(c.Query("search"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
query := h.DB.Model(&domain.User{})
if search != "" {
like := "%" + search + "%"
query = query.Where("email ILIKE ? OR name ILIKE ?", like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var users []domain.User
if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items := make([]userSummary, 0, len(users))
for _, u := range users {
items = append(items, mapUserSummary(u))
}
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
}
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
userID := strings.TrimSpace(c.Params("id"))
if userID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
}
var user domain.User
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(mapUserSummary(user))
}
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
email := strings.TrimSpace(req.Email)
if email == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
}
password := req.Password
if password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"})
}
name := strings.TrimSpace(req.Name)
if name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
// Check duplicates
var exists domain.User
if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
}
user := domain.User{
Email: email,
PasswordHash: string(hashedPassword),
Name: name,
Phone: req.Phone,
Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value.
CompanyCode: req.CompanyCode,
Department: req.Department,
Status: "active",
AffiliationType: "internal", // Defaulting for now
}
if user.Role == "" {
user.Role = "user"
}
if err := h.DB.Create(&user).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user))
}
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
userID := strings.TrimSpace(c.Params("id"))
if userID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
}
var user domain.User
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var req struct {
Password *string `json:"password"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
if req.Name != nil {
user.Name = strings.TrimSpace(*req.Name)
}
if req.Phone != nil {
user.Phone = strings.TrimSpace(*req.Phone)
}
if req.Role != nil {
user.Role = strings.TrimSpace(*req.Role)
}
if req.Status != nil {
status := strings.ToLower(strings.TrimSpace(*req.Status))
if status == "active" || status == "inactive" || status == "blocked" {
user.Status = status
}
}
if req.CompanyCode != nil {
user.CompanyCode = strings.TrimSpace(*req.CompanyCode)
}
if req.Department != nil {
user.Department = strings.TrimSpace(*req.Department)
}
if req.Password != nil && *req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
}
user.PasswordHash = string(hashedPassword)
}
if err := h.DB.Save(&user).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(mapUserSummary(user))
}
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
if h.DB == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
}
userID := strings.TrimSpace(c.Params("id"))
if userID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
}
// Soft delete
if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}
func mapUserSummary(u domain.User) userSummary {
return userSummary{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Phone: u.Phone,
Role: u.Role,
Status: u.Status,
CompanyCode: u.CompanyCode,
Department: u.Department,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
}
}

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
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"sync"
"time"
@@ -21,6 +22,14 @@ type AuditConfig struct {
QueueSize int
}
func isNil(i any) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
return v.Kind() == reflect.Ptr && v.IsNil()
}
// AuditMiddleware provides comprehensive audit logging for all requests.
// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH)
// and best-effort logging for queries (GET, HEAD, OPTIONS).
@@ -153,7 +162,7 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
// 9. Store Log (Policy Enforcement)
_, isWrite := writeMethods[c.Method()]
if config.Repo == nil {
if isNil(config.Repo) {
if isWrite {
slog.Error("Audit repository missing for command", "req_id", reqID)
return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable")

View File

@@ -15,6 +15,21 @@ type ClickHouseRepository struct {
}
func NewClickHouseRepository(host string, port int, user, password, db string) (*ClickHouseRepository, error) {
// 1. Connect to 'default' database first to ensure target DB exists
tmpConn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
Auth: clickhouse.Auth{
Database: "default",
Username: user,
Password: password,
},
})
if err == nil {
_ = tmpConn.Exec(context.Background(), fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", db))
_ = tmpConn.Close()
}
// 2. Now connect to the target database
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
Auth: clickhouse.Auth{

View File

@@ -0,0 +1,34 @@
# Ory Kratos 인증 엔진 전환 작업 보고서
## 1. 개요
기존 Descope SaaS 기반 인증 시스템을 자가 호스팅(Self-hosted) IDP인 **Ory Kratos**로 전환하고, 이를 백엔드(Go Fiber)와 연동하는 작업을 수행하였습니다.
## 2. 주요 작업 내용
### 2.1 인프라 및 SDK 설정
* **SDK 설치**: Ory Kratos Go SDK (`github.com/ory/kratos-client-go`)를 백엔드 프로젝트에 추가.
* **클라이언트 초기화**: `AuthHandler` 내부에 Kratos Public API 통신을 위한 API Client 주입 및 환경 변수 연동.
### 2.2 인증 Flow 핸들러 구현 (`auth_handler.go`)
Ory Kratos의 API-first 방식(Native Flow)에 맞춘 신규 핸들러 구현:
* **InitializeLoginFlow**: 로그인 프로세스 시작을 위한 `flow_id` 발급 API.
* **InitializeRegistrationFlow**: 회원가입 프로세스 시작을 위한 `flow_id` 발급 API.
* **LoginSubmit**: 사용자의 ID/PW를 Kratos에 제출하고 성공 시 세션 쿠키를 클라이언트에 전달.
* **RegistrationSubmit**: 커스텀 Traits(사용자 정보)와 비밀번호를 Kratos에 전달하여 계정 생성.
### 2.3 라우팅 설정 (`main.go`)
신규 인증 엔진을 위한 전용 엔드포인트 그룹 등록:
* `GET /api/v1/auth/ory/login/initialize`
* `POST /api/v1/auth/ory/login/submit`
* `GET /api/v1/auth/ory/registration/initialize`
* `POST /api/v1/auth/ory/registration/submit`
### 2.4 보안 및 감사 (Security & Audit)
* **세션 관리**: Kratos에서 발급한 `Set-Cookie` 헤더를 추출하여 클라이언트에 투명하게 전달(Pass-through).
* **감사 로그**: 로그인 시도 및 성공 시 시각, IP, 대상 아이디 등을 ClickHouse 감사 로그 시스템에 기록.
* **타입 오류 해결**: Kratos SDK의 구조체 타입 미스매치 이슈 해결(`result.Session` nil 비교 로직 수정).
## 3. 향후 과제 (Next Steps)
1. **UI 연동**: `userfront` (Flutter)의 API 엔드포인트를 기존 Descope에서 신규 Ory 경로로 전환.
2. **계정 복구**: 비밀번호 찾기(Recovery) 및 이메일 확인(Verification) Flow 추가 연동.
3. **관리자 기능**: `adminfront`에서 Kratos Identities를 직접 조회/삭제하는 관리 API 연결.

27
docs/kratos-todo-list.md Normal file
View File

@@ -0,0 +1,27 @@
# Kratos 기반 SSO 추가 기능 구현 로드맵
Ory Kratos의 표준 기능을 바탕으로 우리 프로젝트에 추가해야 할 핵심 기능 목록입니다.
## 1. 인증 수단 고도화
- [ ] **소셜 로그인 연동**: Google, GitHub, Apple 등 주요 OIDC 제공자 연결.
- [ ] **Passkeys (WebAuthn)**: 생체 인증을 통한 Passwordless 로그인 구현.
- [ ] **MFA (Multi-Factor Authentication)**: TOTP(Authenticator 앱), Lookup Secret(복구 코드) 지원.
## 2. 사용자 셀프 서비스 (Self-Service)
- [ ] **계정 복구 Flow**: 비밀번호 분실 시 이메일/SMS 링크를 통한 재설정 기능.
- [ ] **계정 확인 (Verification)**: 가입 시 이메일/전화번호 점유 인증 절차.
- [ ] **프로필 및 설정 화면**: 사용자가 직접 자신의 정보(Traits)와 비밀번호를 수정하는 화면.
## 3. 세션 보안 관리
- [ ] **기기별 세션 관리**: 현재 로그인된 모든 브라우저/기기 목록 조회 및 특정 세션 강제 종료 기능.
- [ ] **보안 로그 제공**: 사용자 본인의 최근 로그인 기록 및 보안 이벤트 확인 기능.
## 4. 관리자 기능 (Admin Operations)
- [ ] **커스텀 아이덴티티 스키마**: 테넌트 요구사항에 맞춘 사용자 필드(부서, 직번 등) 동적 정의.
- [ ] **사용자 일괄 마이그레이션**: 외부 데이터 대량 Import/Export API 및 도구.
- [ ] **계정 상태 강제 제어**: 관리자에 의한 계정 잠금(Ban) 및 활성화 처리.
## 5. 시스템 연동 및 브랜딩
- [ ] **메시지 템플릿 관리**: 이메일/SMS 발송 템플릿의 커스텀 HTML 에디터 및 미리보기.
- [ ] **Webhook 이벤트 연동**: 가입/로그인 등 주요 이벤트 발생 시 외부 시스템으로 실시간 데이터 전송.
- [ ] **멀티테넌시 브랜딩**: 접속 도메인이나 테넌트에 따른 로그인 화면 로고/컬러 동적 적용.

84
docs/ory-stack-guide.md Normal file
View File

@@ -0,0 +1,84 @@
# Ory Stack 상세 가이드 (Baron SSO)
이 문서는 Baron SSO의 핵심 엔진인 Ory Stack의 구성 요소와 전체적인 인증/인가 플로우를 설명합니다.
## 1. 구성 요소별 상세 역할
| 구성 요소 | 별칭 | 주요 역할 | 핵심 기능 |
| :------------- | :------------ | :--------------- | :-------------------------------------------- |
| **Kratos** | Identity | **사용자 관리** | 회원가입, 로그인, MFA, 프로필 수정, 계정 복구 |
| | | | |
| **Hydra** | OAuth2/OIDC | **연동 및 토큰** | Access/ID 토큰 발급, 외부 서비스 SSO 연동 |
| **Keto** | Authorization | **권한 제어** | RBAC, ACL, "누가 무엇을 할 수 있는가" 판별 |
| **Oathkeeper** | Proxy/Gateway | **접근 통제** | 요청 검증, 세션 확인, 헤더 변환, API 보호 |
---
## 2. 시스템 플로우 (System Flow)
사용자가 보호된 백엔드 리소스에 접근할 때의 일반적인 흐름입니다.
### [인증 및 접근 흐름]
1. **Request**: 사용자가 API 요청을 보냄 (예: `GET /api/data`).
2. **Intercept (Oathkeeper)**: Oathkeeper가 요청을 가로챔.
3. **Authenticate (Kratos)**: Oathkeeper가 Kratos에게 사용자의 세션 쿠키가 유효한지 확인.
4. **Authorize (Keto)**: Oathkeeper가 Keto에게 해당 사용자가 `/api/data`를 볼 권한이 있는지 확인.
5. **Transform**: 모든 검증이 끝나면 Oathkeeper가 사용자 정보를 헤더(예: `X-User-ID`)에 담아 백엔드로 전달.
6. **Response**: 백엔드가 로직을 수행하고 결과를 반환.
### [SSO 연동 흐름 (OIDC)]
1. **Discovery**: 외부 서비스(App A)가 로그인 필요 시 Hydra로 인증 요청을 보냄.
2. **Login Challenge**: Hydra가 로그인 UI(`userfront`)로 리다이렉트하며 챌린지를 보냄.
3. **Auth (Kratos)**: 사용자가 `userfront`에서 로그인(Kratos 사용).
4. **Accept**: `userfront`가 로그인 성공 시 Hydra에게 챌린지 수락을 알림.
5. **Token Issuance**: Hydra가 App A에게 Auth Code를 주고, App A는 이를 Access/ID Token으로 교환.
---
## 3. 아키텍처 다이어그램
```mermaid
graph TD
User((사용자))
subgraph "Edge / Gateway"
OK[Ory Oathkeeper]
end
subgraph "Identity & Access Layer"
KR[Ory Kratos]
HY[Ory Hydra]
KE[Ory Keto]
end
subgraph "Application Layer"
BE[Backend API]
AF[Admin Front]
UF[User Front]
end
User -->|API Request| OK
User -->|Login/Register| UF
UF --> KR
OK -->|1. 세션 확인| KR
OK -->|2. 권한 확인| KE
OK -->|3. 요청 전달| BE
AF -->|관리 작업| BE
BE -->|Admin API 호출| KR & HY & KE
HY -->|SSO 토큰 발급| User
```
---
## 4. 요약
- **Kratos**는 사용자의 정보를 알고 있습니다.
- **Keto**는 사용자의 권한을 알고 있습니다.
- **Hydra**는 사용자를 외부 서비스에 증명합니다.
- **Oathkeeper**는 위 서비스들을 이용해 입구를 지킵니다.