[feat] Flow Tester 사용자 선택 기능 및 API Explorer 인증 공유

- API Explorer와 세션 토큰 공유 (api_explorer_token, api_explorer_user_id)
- 사용자 선택 드롭다운 UI 추가 (동일 테넌트 사용자 목록)
- HMAC 변수 자동 생성 기능 추가 (\$hmac.exp, \$hmac.signature 등)
- VariableBinder에서 선택된 사용자 정보 사용
- 사용자 선택 시 Sanctum 토큰 자동 발급
This commit is contained in:
2025-12-18 16:08:53 +09:00
parent a62337ef5c
commit e013f5205c
4 changed files with 270 additions and 26 deletions

View File

@@ -25,10 +25,12 @@ public function index(): View
->orderByDesc('created_at')
->paginate(20);
// 세션에 저장된 토큰
$savedToken = session('flow_tester_token');
// 세션에 저장된 토큰 (API Explorer와 공유)
$savedToken = session('api_explorer_token');
$selectedUserId = session('api_explorer_user_id');
$selectedUser = $selectedUserId ? \App\Models\User::find($selectedUserId) : null;
return view('dev-tools.flow-tester.index', compact('flows', 'savedToken'));
return view('dev-tools.flow-tester.index', compact('flows', 'savedToken', 'selectedUser'));
}
/**
@@ -335,12 +337,68 @@ public function runDetail(int $runId): View
/*
|--------------------------------------------------------------------------
| Token Management
| Token & User Management (API Explorer와 공유)
|--------------------------------------------------------------------------
*/
/**
* Bearer 토큰 저장
* 현재 테넌트의 사용자 목록
*/
public function users()
{
$tenantId = auth()->user()->tenant_id;
$users = \App\Models\User::where('tenant_id', $tenantId)
->select(['id', 'name', 'email', 'tenant_id'])
->orderBy('name')
->limit(100)
->get();
return response()->json($users);
}
/**
* 사용자 선택 (Sanctum 토큰 발급)
*/
public function selectUser(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|integer',
]);
$user = \App\Models\User::find($validated['user_id']);
if (! $user) {
return response()->json([
'success' => false,
'message' => '사용자를 찾을 수 없습니다.',
], 404);
}
// Sanctum 토큰 발급
$token = $user->createToken('flow-tester', ['*'])->plainTextToken;
// 세션에 저장 (API Explorer와 공유)
session([
'api_explorer_token' => $token,
'api_explorer_user_id' => $user->id,
]);
return response()->json([
'success' => true,
'message' => "'{$user->name}' 사용자로 인증되었습니다.",
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tenant_id' => $user->tenant_id,
],
'token_preview' => substr($token, 0, 20).'...',
]);
}
/**
* Bearer 토큰 저장 (직접 입력)
*/
public function saveToken(Request $request)
{
@@ -348,7 +406,11 @@ public function saveToken(Request $request)
'token' => 'required|string',
]);
session(['flow_tester_token' => $validated['token']]);
// API Explorer와 같은 세션 키 사용
session([
'api_explorer_token' => $validated['token'],
'api_explorer_user_id' => null, // 직접 입력 시 사용자 정보 없음
]);
return response()->json([
'success' => true,
@@ -361,11 +423,11 @@ public function saveToken(Request $request)
*/
public function clearToken()
{
session()->forget('flow_tester_token');
session()->forget(['api_explorer_token', 'api_explorer_user_id']);
return response()->json([
'success' => true,
'message' => '토큰이 초기화되었습니다.',
'message' => '인증이 초기화되었습니다.',
]);
}
@@ -374,11 +436,19 @@ public function clearToken()
*/
public function tokenStatus()
{
$token = session('flow_tester_token');
$token = session('api_explorer_token');
$userId = session('api_explorer_user_id');
$user = $userId ? \App\Models\User::find($userId) : null;
return response()->json([
'has_token' => ! empty($token),
'token_preview' => $token ? substr($token, 0, 20).'...' : null,
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'tenant_id' => $user->tenant_id,
] : null,
]);
}
}

View File

@@ -17,6 +17,10 @@
* - {{$uuid}} - 랜덤 UUID
* - {{$random:N}} - N자리 랜덤 숫자
* - {{$session.token}} - 세션에 저장된 Bearer 토큰
* - {{$hmac.exp}} - HMAC 만료 시간 (현재 + 300초)
* - {{$hmac.signature}} - HMAC-SHA256 서명
* - {{$hmac.user_id}} - 현재 로그인 사용자 ID
* - {{$hmac.tenant_id}} - 현재 테넌트 ID
* - {{$faker.xxx}} - Faker 기반 랜덤 데이터 생성
*/
class VariableBinder
@@ -152,12 +156,15 @@ function ($m) {
// {{$auth.apiKey}} → .env의 API Key
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
// {{$session.token}} → 세션에 저장된 Bearer 토큰
// {{$session.token}} → 세션에 저장된 Bearer 토큰 (API Explorer와 공유)
if (str_contains($input, '{{$session.token}}')) {
$token = session('flow_tester_token', '');
$token = session('api_explorer_token', '');
$input = str_replace('{{$session.token}}', $token, $input);
}
// {{$hmac.xxx}} → HMAC 인증용 동적 값 생성
$input = $this->resolveHmac($input);
// {{$faker.xxx}} → Faker 기반 랜덤 데이터 생성
$input = $this->resolveFaker($input);
@@ -339,6 +346,58 @@ private function getAuthToken(): string
return env('FLOW_TESTER_API_TOKEN', '');
}
/**
* HMAC 인증용 동적 값 생성
*
* 지원 패턴:
* - {{$hmac.exp}} - 만료 시간 (현재 + 300초)
* - {{$hmac.user_id}} - 선택된 사용자 ID (또는 현재 로그인 사용자)
* - {{$hmac.tenant_id}} - 선택된 사용자의 테넌트 ID
* - {{$hmac.signature}} - HMAC-SHA256 서명
*
* 서명 생성 방식:
* - payload: "{user_id}:{tenant_id}:{exp}"
* - signature: hash_hmac('sha256', payload, exchange_secret)
*
* 세션에서 선택된 사용자 정보를 우선 사용 (API Explorer와 공유)
*/
private function resolveHmac(string $input): string
{
// HMAC 변수가 없으면 조기 반환
if (! str_contains($input, '{{$hmac.')) {
return $input;
}
// 세션에서 선택된 사용자 ID 가져오기 (API Explorer와 공유)
$selectedUserId = session('api_explorer_user_id');
// 선택된 사용자가 있으면 해당 사용자 정보 사용, 없으면 현재 로그인 사용자
if ($selectedUserId) {
$user = \App\Models\User::find($selectedUserId);
} else {
$user = auth()->user();
}
$userId = $user?->id ?? env('FLOW_TESTER_USER_ID', '');
$tenantId = $user?->tenant_id ?? env('FLOW_TESTER_TENANT_ID', '');
// 만료 시간 (현재 + 300초)
$exp = time() + 300;
// 서명 생성
$exchangeSecret = config('services.api.exchange_secret', '');
$payload = "{$userId}:{$tenantId}:{$exp}";
$signature = hash_hmac('sha256', $payload, $exchangeSecret);
// 각 HMAC 변수 치환
$input = str_replace('{{$hmac.exp}}', (string) $exp, $input);
$input = str_replace('{{$hmac.user_id}}', (string) $userId, $input);
$input = str_replace('{{$hmac.tenant_id}}', (string) $tenantId, $input);
$input = str_replace('{{$hmac.signature}}', $signature, $input);
return $input;
}
/**
* 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값)
*

View File

@@ -13,7 +13,13 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span id="auth-status" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
@if($selectedUser ?? null)
{{ $selectedUser->name }}
@elseif($savedToken)
인증됨
@else
인증 필요
@endif
</span>
</button>
<!-- JSON 작성 가이드 버튼 -->
@@ -448,16 +454,40 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
</button>
</div>
<!-- 토큰 입력 -->
<!-- 사용자 선택 (API Explorer와 공유) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰</label>
<label class="block text-sm font-medium text-gray-700 mb-1">
사용자 선택
<span class="text-xs text-gray-500 font-normal ml-1">(API Explorer와 공유)</span>
</label>
<div class="flex gap-2">
<select id="userSelect" class="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">-- 사용자를 선택하세요 --</option>
</select>
<button type="button" onclick="selectUser()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition text-sm">
선택
</button>
</div>
<p class="mt-1 text-xs text-gray-500">사용자를 선택하면 해당 사용자의 Sanctum 토큰이 자동 발급됩니다.</p>
</div>
<!-- 구분선 -->
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-200"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">또는</span>
</div>
</div>
<!-- 토큰 직접 입력 -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer 토큰 직접 입력</label>
<input type="text" id="authBearerToken" placeholder="Bearer 토큰을 입력하세요"
class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value="{{ $savedToken ?? '' }}">
@if($savedToken)
<p class="mt-1 text-xs text-green-600"> 세션에 저장된 토큰이 있습니다.</p>
@endif
<p class="mt-1 text-xs text-gray-500">플로우에서 <code class="bg-gray-100 px-1 rounded">\{\{$session.token\}\}</code> 참조할 있습니다.</p>
<p class="mt-1 text-xs text-gray-500">플로우에서 <code class="bg-gray-100 px-1 rounded">@{{$session.token}}</code> 참조할 있습니다.</p>
</div>
<!-- 현재 인증 상태 -->
@@ -465,9 +495,20 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
<div class="text-sm text-gray-600">
<span class="font-medium">현재 상태:</span>
<span id="authStatusDisplay" class="{{ $savedToken ? 'text-green-600' : 'text-gray-500' }}">
{{ $savedToken ? '인증됨' : '인증 필요' }}
@if($selectedUser ?? null)
{{ $selectedUser->name }} ({{ $selectedUser->email }})
@elseif($savedToken)
인증됨
@else
인증 필요
@endif
</span>
</div>
@if($selectedUser ?? null)
<div class="text-xs text-gray-500 mt-1">
tenant_id: {{ $selectedUser->tenant_id }}, user_id: {{ $selectedUser->id }}
</div>
@endif
</div>
<!-- 버튼 -->
@@ -479,7 +520,7 @@ class="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-5
닫기
</button>
<button type="button" onclick="saveAuth()" class="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition">
저장
토큰 저장
</button>
</div>
</div>
@@ -729,16 +770,85 @@ function confirmDelete(id, name) {
// 현재 토큰 상태
let currentAuthToken = @json($savedToken ?? '');
let usersLoaded = false;
function openAuthModal() {
document.getElementById('authModal').classList.remove('hidden');
document.getElementById('authBearerToken').value = currentAuthToken || '';
// 사용자 목록 로딩 (최초 1회)
if (!usersLoaded) {
loadUsers();
}
}
function closeAuthModal() {
document.getElementById('authModal').classList.add('hidden');
}
// 사용자 목록 로딩
function loadUsers() {
const select = document.getElementById('userSelect');
select.innerHTML = '<option value="">로딩 중...</option>';
fetch('{{ route("dev-tools.flow-tester.users") }}', {
headers: {
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(users => {
select.innerHTML = '<option value="">-- 사용자를 선택하세요 --</option>';
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.name} (${user.email}) - tenant: ${user.tenant_id}`;
select.appendChild(option);
});
usersLoaded = true;
})
.catch(error => {
select.innerHTML = '<option value="">사용자 로딩 실패</option>';
console.error('사용자 목록 로딩 실패:', error);
});
}
// 사용자 선택 (Sanctum 토큰 발급)
function selectUser() {
const select = document.getElementById('userSelect');
const userId = select.value;
if (!userId) {
showToast('사용자를 선택해주세요.', 'warning');
return;
}
fetch('{{ route("dev-tools.flow-tester.user.select") }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ user_id: parseInt(userId) }),
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 페이지 새로고침하여 상태 반영
showToast(data.message, 'success');
setTimeout(() => {
location.reload();
}, 500);
} else {
showToast(data.message || '사용자 선택 실패', 'error');
}
})
.catch(error => {
showToast('오류 발생: ' + error.message, 'error');
});
}
function saveAuth() {
const token = document.getElementById('authBearerToken').value.trim();
@@ -761,7 +871,7 @@ function saveAuth() {
.then(data => {
if (data.success) {
currentAuthToken = token;
updateAuthStatus(true);
updateAuthStatus(true, null);
showToast('토큰이 저장되었습니다.', 'success');
closeAuthModal();
} else {
@@ -787,8 +897,11 @@ function clearAuth() {
if (data.success) {
currentAuthToken = '';
document.getElementById('authBearerToken').value = '';
updateAuthStatus(false);
updateAuthStatus(false, null);
showToast('인증이 초기화되었습니다.', 'info');
setTimeout(() => {
location.reload();
}, 500);
} else {
showToast(data.message || '초기화 실패', 'error');
}
@@ -798,17 +911,17 @@ function clearAuth() {
});
}
function updateAuthStatus(isAuthenticated) {
function updateAuthStatus(isAuthenticated, userName) {
const statusEl = document.getElementById('auth-status');
const modalStatusEl = document.getElementById('authStatusDisplay');
if (isAuthenticated) {
statusEl.textContent = '인증됨';
statusEl.textContent = userName || '인증됨';
statusEl.classList.remove('text-gray-500');
statusEl.classList.add('text-green-600');
if (modalStatusEl) {
modalStatusEl.textContent = '인증됨';
modalStatusEl.textContent = userName ? `✅ ${userName}` : '인증됨';
modalStatusEl.classList.remove('text-gray-500');
modalStatusEl.classList.add('text-green-600');
}

View File

@@ -283,7 +283,9 @@
Route::post('/', [FlowTesterController::class, 'store'])->name('store');
Route::post('/validate-json', [FlowTesterController::class, 'validateJson'])->name('validate-json');
// 토큰 관리 라우트
// 토큰 및 사용자 관리 라우트 (API Explorer와 공유)
Route::get('/users', [FlowTesterController::class, 'users'])->name('users');
Route::post('/user/select', [FlowTesterController::class, 'selectUser'])->name('user.select');
Route::post('/token/save', [FlowTesterController::class, 'saveToken'])->name('token.save');
Route::post('/token/clear', [FlowTesterController::class, 'clearToken'])->name('token.clear');
Route::get('/token/status', [FlowTesterController::class, 'tokenStatus'])->name('token.status');