diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index a390a46e..262320a1 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -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: }, { path: "audit-logs", element: }, { path: "auth", element: }, + { path: "users", element: }, + { path: "users/new", element: }, + { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, { path: "tenants/:id", element: }, + { path: "api-keys", element: }, + { path: "api-keys/new", element: }, ], }, ], diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index a412692a..c7e651a7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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 }, ]; diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx new file mode 100644 index 00000000..09280945 --- /dev/null +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -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(null); + const [createdResult, setCreatedResult] = React.useState(null); + const [selectedScopes, setSelectedScopes] = React.useState(["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 ( +
+
+
+ +
+

API 키 생성 완료

+

+ 아래의 비밀번호(Secret)는 보안을 위해 지금 한 번만 표시됩니다. +

+
+ + + + + + 보안 시크릿 복사 + + + +
+ +
+ + +
+

+ 복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요. +

+
+ +
+ +
+
+
+
+ ); + } + + return ( +
+
+
+ +

새 API 키 생성

+

내부 시스템 연동을 위한 보안 인증 키를 구성합니다.

+
+
+ +
+ {/* 섹션 1: 이름 설정 */} +
+
+ 1 +

키 이름 지정

+
+ + +
+ + + {errors.name &&

{errors.name.message}

} +
+
+
+
+ + {/* 섹션 2: 권한 선택 */} +
+
+ 2 +

권한 범위(Scopes) 선택

+
+
+ {AVAILABLE_SCOPES.map((scope) => { + const isSelected = selectedScopes.includes(scope.id); + return ( + + ); + })} +
+
+ + {/* 하단 실행 버튼 */} +
+ {error && ( +
+ +

{error}

+
+ )} + +
+
+

총 {selectedScopes.length}개의 권한이 할당됩니다.

+

생성 즉시 활성화되어 사용 가능합니다.

+
+ +
+
+
+
+ ); +} + +export default ApiKeyCreatePage; \ No newline at end of file diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx new file mode 100644 index 00000000..1be12120 --- /dev/null +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -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 ( +
+
+
+
+ API Keys + / + List +
+

API 키 관리 (M2M)

+

+ 서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 + 관리합니다. +

+
+
+ + +
+
+ + + +
+ API Key Registry + + 총 {query.data?.total ?? 0}개 API 키 + +
+ System +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + NAME + CLIENT ID + SCOPES + LAST USED + ACTIONS + + + + {query.isLoading && ( + + 로딩 중... + + )} + {!query.isLoading && items.length === 0 && ( + + 등록된 API 키가 없습니다. + + )} + {items.map((key) => ( + + +
+ + {key.name} +
+
+ + {key.client_id} + + +
+ {key.scopes.map((scope) => ( + + {scope} + + ))} +
+
+ + {key.lastUsedAt + ? new Date(key.lastUsedAt).toLocaleString("ko-KR") + : "Never"} + + + + +
+ ))} +
+
+
+
+
+ ); +} + +export default ApiKeyListPage; diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index 5bb32f46..11522c12 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -559,4 +559,4 @@ function AuditLogsPage() { ); } -export default AuditLogsPage; +export default AuditLogsPage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx new file mode 100644 index 00000000..3fb5c9c4 --- /dev/null +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -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(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( +
+
+
+
+ + Users + + / + New +
+

사용자 추가

+
+ +
+ + + + 계정 정보 + + 새로운 사용자를 시스템에 등록합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + +

+ 초기 비밀번호를 설정합니다. +

+ {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+

+ 시스템 접근 권한을 결정합니다. +

+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserCreatePage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx new file mode 100644 index 00000000..afb4512a --- /dev/null +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -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(null); + const [successMsg, setSuccessMsg] = React.useState(null); + + const { data: user, isLoading, isError } = useQuery({ + queryKey: ["user", id], + queryFn: () => fetchUser(id!), + enabled: !!id, + }); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + 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
Loading...
; + } + + if (isError || !user) { + return ( +
+ 사용자를 찾을 수 없습니다. +
+ ); + } + + return ( +
+
+
+
+ + Users + + / + {user.name} +
+

사용자 상세

+
+ +
+ + + + 정보 수정 + + {user.email} 계정의 정보를 수정합니다. + + + +
+ {error && ( +
+ {error} +
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + +
+
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+

보안 설정

+
+ + +

+ 비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다. +

+
+
+ +
+ + +
+
+
+
+
+ ); +} + +export default UserDetailPage; \ No newline at end of file diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx new file mode 100644 index 00000000..1eb631be --- /dev/null +++ b/adminfront/src/features/users/UserListPage.tsx @@ -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 ( +
+
+
+
+ Users + / + List +
+

사용자 관리

+

+ 시스템 사용자를 조회하고 관리합니다. (Local DB) +

+
+
+ + +
+
+ + + +
+ User Registry + + 총 {total}명의 사용자가 등록되어 있습니다. + +
+
+ +
+
+ + setSearchDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + +
+ + + + NAME / EMAIL + ROLE + STATUS + COMPANY / DEPT + CREATED + ACTIONS + + + + {query.isLoading && ( + + + 로딩 중... + + + )} + {!query.isLoading && items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {items.map((user) => ( + + +
+
+ +
+
+ {user.name} + + {user.email} + +
+
+
+ + {user.role} + + + + {user.status} + + + +
+ {user.companyCode || "-"} + + {user.department || "-"} + +
+
+ + {new Date(user.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ Page {page} of {totalPages} +
+ +
+ )} +
+
+
+ ); +} + +export default UserListPage; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index ebdfd38d..965d5092 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -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("/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( + "/v1/admin/api-keys", + { + params: { limit, offset }, + }, + ); + return data; +} + +export async function createApiKey(payload: ApiKeyCreateRequest) { + const { data } = await apiClient.post( + "/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("/v1/admin/users", { + params: { limit, offset, search }, + }); + return data; +} + +export async function fetchUser(userId: string) { + const { data } = await apiClient.get( + `/v1/admin/users/${userId}`, + ); + return data; +} + +export async function createUser(payload: UserCreateRequest) { + const { data } = await apiClient.post( + "/v1/admin/users", + payload, + ); + return data; +} + +export async function updateUser(userId: string, payload: UserUpdateRequest) { + const { data } = await apiClient.put( + `/v1/admin/users/${userId}`, + payload, + ); + return data; +} + +export async function deleteUser(userId: string) { + await apiClient.delete(`/v1/admin/users/${userId}`); +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ec8060b0..c57a8608 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 45846451..c6f6993a 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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) diff --git a/backend/internal/domain/api_key.go b/backend/internal/domain/api_key.go new file mode 100644 index 00000000..dc4d86fa --- /dev/null +++ b/backend/internal/domain/api_key.go @@ -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 +} diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go new file mode 100644 index 00000000..bb1d8afb --- /dev/null +++ b/backend/internal/handler/api_key_handler.go @@ -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) +} diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index 07c4e049..d246b352 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -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{ diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go new file mode 100644 index 00000000..8dee9917 --- /dev/null +++ b/backend/internal/handler/user_handler.go @@ -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), + } +} diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go new file mode 100644 index 00000000..a5aa8b86 --- /dev/null +++ b/backend/internal/middleware/api_key_auth.go @@ -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 +} diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index ea26520a..a010b8eb 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -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") diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index c6c8723f..141a04e0 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -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{ diff --git a/docs/kratos-integration-report.md b/docs/kratos-integration-report.md new file mode 100644 index 00000000..2fd69fb7 --- /dev/null +++ b/docs/kratos-integration-report.md @@ -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 연결. diff --git a/docs/kratos-todo-list.md b/docs/kratos-todo-list.md new file mode 100644 index 00000000..8e331c4f --- /dev/null +++ b/docs/kratos-todo-list.md @@ -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 이벤트 연동**: 가입/로그인 등 주요 이벤트 발생 시 외부 시스템으로 실시간 데이터 전송. +- [ ] **멀티테넌시 브랜딩**: 접속 도메인이나 테넌트에 따른 로그인 화면 로고/컬러 동적 적용. diff --git a/docs/ory-stack-guide.md b/docs/ory-stack-guide.md new file mode 100644 index 00000000..51438e26 --- /dev/null +++ b/docs/ory-stack-guide.md @@ -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**는 위 서비스들을 이용해 입구를 지킵니다.