feat: API 토큰 교환 연동 (FCM 푸시용)
- ApiTokenService: API 서버 토큰 교환 서비스 추가 - AuthService: 로그인 성공 시 API 토큰 교환 연동 - 레이아웃: 세션 토큰을 localStorage에 동기화 (FCM 사용) - config/services.php: exchange_secret 설정 추가 환경변수 필요: INTERNAL_EXCHANGE_SECRET (API와 동일)
This commit is contained in:
141
app/Services/ApiTokenService.php
Normal file
141
app/Services/ApiTokenService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* API 서버 토큰 교환 서비스
|
||||
*
|
||||
* MNG → API 서버간 HMAC 인증을 통해 Bearer 토큰을 발급받습니다.
|
||||
*/
|
||||
class ApiTokenService
|
||||
{
|
||||
/**
|
||||
* 서명 유효 시간 (초) - API 서버와 동일하게 5분
|
||||
*/
|
||||
private const SIGNATURE_VALID_DURATION = 300;
|
||||
|
||||
/**
|
||||
* API 토큰 교환
|
||||
*
|
||||
* @param int $userId 사용자 ID
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @return array{success: bool, data?: array, error?: string}
|
||||
*/
|
||||
public function exchangeToken(int $userId, int $tenantId): array
|
||||
{
|
||||
$baseUrl = config('services.api.base_url');
|
||||
$exchangeSecret = config('services.api.exchange_secret');
|
||||
|
||||
if (empty($baseUrl)) {
|
||||
Log::error('[ApiTokenService] API base URL not configured');
|
||||
|
||||
return ['success' => false, 'error' => 'API 서버 URL이 설정되지 않았습니다.'];
|
||||
}
|
||||
|
||||
if (empty($exchangeSecret)) {
|
||||
Log::error('[ApiTokenService] Exchange secret not configured');
|
||||
|
||||
return ['success' => false, 'error' => '토큰 교환 비밀키가 설정되지 않았습니다.'];
|
||||
}
|
||||
|
||||
// 만료 시간 계산 (현재 시간 + 유효 시간)
|
||||
$exp = time() + self::SIGNATURE_VALID_DURATION;
|
||||
|
||||
// HMAC 서명 생성
|
||||
$payload = "{$userId}:{$tenantId}:{$exp}";
|
||||
$signature = hash_hmac('sha256', $payload, $exchangeSecret);
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->post("{$baseUrl}/api/v1/internal/exchange-token", [
|
||||
'user_id' => $userId,
|
||||
'tenant_id' => $tenantId,
|
||||
'exp' => $exp,
|
||||
'signature' => $signature,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json('data');
|
||||
Log::info('[ApiTokenService] Token exchanged successfully', [
|
||||
'user_id' => $userId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'access_token' => $data['access_token'],
|
||||
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||
'expires_in' => $data['expires_in'] ?? 3600,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$error = $response->json('message') ?? '토큰 교환에 실패했습니다.';
|
||||
Log::warning('[ApiTokenService] Token exchange failed', [
|
||||
'user_id' => $userId,
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $response->status(),
|
||||
'error' => $error,
|
||||
]);
|
||||
|
||||
return ['success' => false, 'error' => $error];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[ApiTokenService] Token exchange exception', [
|
||||
'user_id' => $userId,
|
||||
'tenant_id' => $tenantId,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['success' => false, 'error' => 'API 서버 연결에 실패했습니다.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션에 저장된 API 토큰 조회
|
||||
*/
|
||||
public function getSessionToken(): ?string
|
||||
{
|
||||
return session('api_access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* API 토큰을 세션에 저장
|
||||
*
|
||||
* @param string $token Bearer 토큰
|
||||
* @param int $expiresIn 만료 시간 (초)
|
||||
*/
|
||||
public function storeTokenInSession(string $token, int $expiresIn): void
|
||||
{
|
||||
session([
|
||||
'api_access_token' => $token,
|
||||
'api_token_expires_at' => now()->addSeconds($expiresIn)->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션의 API 토큰 삭제
|
||||
*/
|
||||
public function clearSessionToken(): void
|
||||
{
|
||||
session()->forget(['api_access_token', 'api_token_expires_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰이 만료되었는지 확인
|
||||
*/
|
||||
public function isTokenExpired(): bool
|
||||
{
|
||||
$expiresAt = session('api_token_expires_at');
|
||||
|
||||
if (! $expiresAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5분 전에 미리 갱신하도록 (버퍼)
|
||||
return time() > ($expiresAt - 300);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
@@ -13,6 +14,10 @@ class AuthService
|
||||
*/
|
||||
private ?string $loginError = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiTokenService $apiTokenService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 웹 세션 로그인
|
||||
* - 본사(HQ) 테넌트 소속만 로그인 허용
|
||||
@@ -50,11 +55,46 @@ public function login(array $credentials, bool $remember = false): bool
|
||||
$hqTenant = $user->getHQTenant();
|
||||
if ($hqTenant) {
|
||||
session(['selected_tenant_id' => $hqTenant->id]);
|
||||
|
||||
// 5. API 서버 토큰 교환 (FCM 등 API 호출용)
|
||||
$this->exchangeApiToken($user->id, $hqTenant->id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 서버 토큰 교환
|
||||
* 실패해도 로그인은 허용 (FCM 기능만 제한됨)
|
||||
*
|
||||
* @param int $userId 사용자 ID
|
||||
* @param int $tenantId 테넌트 ID
|
||||
*/
|
||||
private function exchangeApiToken(int $userId, int $tenantId): void
|
||||
{
|
||||
try {
|
||||
$result = $this->apiTokenService->exchangeToken($userId, $tenantId);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->apiTokenService->storeTokenInSession(
|
||||
$result['data']['access_token'],
|
||||
$result['data']['expires_in']
|
||||
);
|
||||
Log::info('[AuthService] API token exchanged', ['user_id' => $userId]);
|
||||
} else {
|
||||
Log::warning('[AuthService] API token exchange failed', [
|
||||
'user_id' => $userId,
|
||||
'error' => $result['error'] ?? 'Unknown error',
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('[AuthService] API token exchange exception', [
|
||||
'user_id' => $userId,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 로그인 실패 사유 조회
|
||||
*/
|
||||
@@ -68,6 +108,9 @@ public function getLoginError(): ?string
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
// API 토큰 정리
|
||||
$this->apiTokenService->clearSessionToken();
|
||||
|
||||
Auth::logout();
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
'api' => [
|
||||
'base_url' => env('API_BASE_URL'),
|
||||
'key' => env('FLOW_TESTER_API_KEY'),
|
||||
'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -6,6 +6,37 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Dashboard') - {{ config('app.name') }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
<!-- SAM 전역 설정 (FCM 등에서 사용) -->
|
||||
<script>
|
||||
window.SAM_CONFIG = {
|
||||
apiBaseUrl: '{{ config('services.api.base_url', 'https://api.codebridge-x.com') }}',
|
||||
apiKey: '{{ config('services.api.key', '') }}',
|
||||
appVersion: '{{ config('app.version', '1.0.0') }}',
|
||||
};
|
||||
|
||||
// API 토큰 localStorage 동기화 (FCM 등에서 사용)
|
||||
@if(session('api_access_token'))
|
||||
(function() {
|
||||
const token = '{{ session('api_access_token') }}';
|
||||
const expiresAt = {{ session('api_token_expires_at', 0) }};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// 토큰이 유효한 경우에만 저장
|
||||
if (expiresAt > now) {
|
||||
localStorage.setItem('api_access_token', token);
|
||||
localStorage.setItem('api_token_expires_at', expiresAt);
|
||||
} else {
|
||||
// 만료된 토큰 정리
|
||||
localStorage.removeItem('api_access_token');
|
||||
localStorage.removeItem('api_token_expires_at');
|
||||
}
|
||||
})();
|
||||
@else
|
||||
// 세션에 토큰이 없으면 localStorage도 정리
|
||||
localStorage.removeItem('api_access_token');
|
||||
localStorage.removeItem('api_token_expires_at');
|
||||
@endif
|
||||
</script>
|
||||
<!-- 사이드바 상태 즉시 적용 (깜빡임 방지) -->
|
||||
<script>
|
||||
(function() {
|
||||
@@ -57,6 +88,8 @@
|
||||
<script src="{{ asset('js/context-menu.js') }}"></script>
|
||||
<script src="{{ asset('js/tenant-modal.js') }}"></script>
|
||||
<script src="{{ asset('js/user-modal.js') }}"></script>
|
||||
<!-- FCM Push Notification (Capacitor 앱에서만 동작) -->
|
||||
<script src="{{ asset('js/fcm.js') }}"></script>
|
||||
|
||||
<!-- SweetAlert2 공통 함수 (Tailwind 테마) -->
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user