feat: API 토큰 교환 연동 (FCM 푸시용)

- ApiTokenService: API 서버 토큰 교환 서비스 추가
- AuthService: 로그인 성공 시 API 토큰 교환 연동
- 레이아웃: 세션 토큰을 localStorage에 동기화 (FCM 사용)
- config/services.php: exchange_secret 설정 추가

환경변수 필요: INTERNAL_EXCHANGE_SECRET (API와 동일)
This commit is contained in:
2025-12-18 14:21:50 +09:00
parent c94e1cff41
commit 2ed273097e
4 changed files with 218 additions and 0 deletions

View 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);
}
}

View File

@@ -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();
}

View File

@@ -61,6 +61,7 @@
'api' => [
'base_url' => env('API_BASE_URL'),
'key' => env('FLOW_TESTER_API_KEY'),
'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'),
],
];

View File

@@ -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>