[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:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 → 실제 값)
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user