feat: MNG→API 토큰 교환 엔드포인트 추가

- POST /api/v1/internal/exchange-token 추가
- HMAC-SHA256 서명 기반 서버간 인증
- InternalTokenService: 서명 검증 및 Sanctum 토큰 발급
- ExchangeTokenRequest: 요청 검증 (user_id, tenant_id, exp, signature)
- ApiKeyMiddleware: 내부 통신 경로 화이트리스트 추가
- i18n 메시지 추가 (error.internal.*, message.internal.*)

환경변수 필요: INTERNAL_EXCHANGE_SECRET (MNG와 동일)
This commit is contained in:
2025-12-18 14:21:37 +09:00
parent 98645316fc
commit 8b30a555d2
7 changed files with 312 additions and 7 deletions

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Internal\ExchangeTokenRequest;
use App\Services\InternalTokenService;
/**
* 내부 서버간 통신 컨트롤러
*
* MNG → API 서버간 인증 토큰 교환 등을 처리합니다.
*/
class InternalController extends Controller
{
public function __construct(
private InternalTokenService $tokenService
) {}
/**
* 토큰 교환
*
* MNG 서버에서 HMAC 서명된 페이로드로 API 토큰을 발급받습니다.
*/
public function exchangeToken(ExchangeTokenRequest $request)
{
$validated = $request->validated();
$result = $this->tokenService->exchange(
userId: $validated['user_id'],
tenantId: $validated['tenant_id'],
exp: $validated['exp'],
signature: $validated['signature']
);
if (! $result['success']) {
return ApiResponse::error($result['error'], 401);
}
return ApiResponse::success(
data: $result['data'],
message: __('message.internal.token_exchanged')
);
}
}

View File

@@ -21,6 +21,7 @@ public function handle(Request $request, Closure $next)
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',
'api/v1/debug-apikey', 'api/v1/debug-apikey',
'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용)
'api-docs', // Swagger UI 'api-docs', // Swagger UI
'api-docs/*', // Swagger 하위 경로 'api-docs/*', // Swagger 하위 경로
'docs', // L5-Swagger UI 'docs', // L5-Swagger UI
@@ -120,6 +121,7 @@ public function handle(Request $request, Closure $next)
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',
'api/v1/debug-apikey', 'api/v1/debug-apikey',
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
// 추가적으로 허용하고 싶은 라우트 // 추가적으로 허용하고 싶은 라우트
]; ];

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Internal;
use Illuminate\Foundation\Http\FormRequest;
/**
* 토큰 교환 요청 검증
*/
class ExchangeTokenRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => ['required', 'integer', 'min:1'],
'tenant_id' => ['required', 'integer', 'min:1'],
'exp' => ['required', 'integer', 'min:1'],
'signature' => ['required', 'string', 'size:64'], // SHA256 hex = 64 chars
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'user_id.required' => __('validation.required', ['attribute' => 'user_id']),
'user_id.integer' => __('validation.integer', ['attribute' => 'user_id']),
'tenant_id.required' => __('validation.required', ['attribute' => 'tenant_id']),
'tenant_id.integer' => __('validation.integer', ['attribute' => 'tenant_id']),
'exp.required' => __('validation.required', ['attribute' => 'exp']),
'exp.integer' => __('validation.integer', ['attribute' => 'exp']),
'signature.required' => __('validation.required', ['attribute' => 'signature']),
'signature.size' => __('validation.size.string', ['attribute' => 'signature', 'size' => 64]),
];
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Models\Members\User;
use Illuminate\Support\Facades\Log;
/**
* 내부 서버간 토큰 교환 서비스
*
* MNG 서버에서 HMAC 서명된 페이로드를 검증하고
* API 사용을 위한 Sanctum 토큰을 발급합니다.
*/
class InternalTokenService
{
/**
* 토큰 유효 시간 (초) - 기본 1시간
*/
private const TOKEN_EXPIRES_IN = 3600;
/**
* 서명 유효 시간 (초) - 기본 5분
*/
private const SIGNATURE_VALID_DURATION = 300;
/**
* 서명된 페이로드 검증
*
* @param int $userId 사용자 ID
* @param int $tenantId 테넌트 ID
* @param int $exp 만료 타임스탬프
* @param string $signature HMAC 서명
* @return array{valid: bool, error?: string}
*/
public function verifySignature(int $userId, int $tenantId, int $exp, string $signature): array
{
$sharedSecret = config('services.internal.exchange_secret');
if (empty($sharedSecret)) {
Log::error('[InternalTokenService] exchange_secret not configured');
return ['valid' => false, 'error' => __('error.internal.secret_not_configured')];
}
// 만료 시간 검증 (현재 시간 기준 SIGNATURE_VALID_DURATION 초 이내)
$now = time();
if ($exp < $now) {
Log::warning('[InternalTokenService] Signature expired', [
'exp' => $exp,
'now' => $now,
'diff' => $now - $exp,
]);
return ['valid' => false, 'error' => __('error.internal.signature_expired')];
}
if ($exp > $now + self::SIGNATURE_VALID_DURATION) {
Log::warning('[InternalTokenService] Signature exp too far in future', [
'exp' => $exp,
'now' => $now,
'diff' => $exp - $now,
]);
return ['valid' => false, 'error' => __('error.internal.invalid_exp')];
}
// HMAC 서명 검증
$payload = "{$userId}:{$tenantId}:{$exp}";
$expectedSignature = hash_hmac('sha256', $payload, $sharedSecret);
if (! hash_equals($expectedSignature, $signature)) {
Log::warning('[InternalTokenService] Invalid signature', [
'user_id' => $userId,
'tenant_id' => $tenantId,
]);
return ['valid' => false, 'error' => __('error.internal.invalid_signature')];
}
return ['valid' => true];
}
/**
* 사용자 토큰 발급
*
* @param int $userId 사용자 ID
* @param int $tenantId 테넌트 ID
* @return array{access_token: string, token_type: string, expires_in: int}|null
*/
public function issueToken(int $userId, int $tenantId): ?array
{
$user = User::find($userId);
if (! $user) {
Log::warning('[InternalTokenService] User not found', ['user_id' => $userId]);
return null;
}
// 해당 테넌트에 소속되어 있는지 확인
$userTenant = $user->userTenants()->where('tenant_id', $tenantId)->first();
if (! $userTenant) {
Log::warning('[InternalTokenService] User not in tenant', [
'user_id' => $userId,
'tenant_id' => $tenantId,
]);
return null;
}
// 기존 mng_session 토큰 삭제 (중복 방지)
$user->tokens()->where('name', 'mng_session')->delete();
// 새 토큰 발급
$token = $user->createToken('mng_session', ['*'], now()->addSeconds(self::TOKEN_EXPIRES_IN));
Log::info('[InternalTokenService] Token issued', [
'user_id' => $userId,
'tenant_id' => $tenantId,
'token_id' => $token->accessToken->id,
]);
return [
'access_token' => $token->plainTextToken,
'token_type' => 'Bearer',
'expires_in' => self::TOKEN_EXPIRES_IN,
];
}
/**
* 토큰 교환 실행 (검증 + 발급)
*
* @param int $userId 사용자 ID
* @param int $tenantId 테넌트 ID
* @param int $exp 만료 타임스탬프
* @param string $signature HMAC 서명
* @return array{success: bool, data?: array, error?: string}
*/
public function exchange(int $userId, int $tenantId, int $exp, string $signature): array
{
// 1. 서명 검증
$verification = $this->verifySignature($userId, $tenantId, $exp, $signature);
if (! $verification['valid']) {
return [
'success' => false,
'error' => $verification['error'],
];
}
// 2. 토큰 발급
$tokenData = $this->issueToken($userId, $tenantId);
if (! $tokenData) {
return [
'success' => false,
'error' => __('error.internal.token_issue_failed'),
];
}
return [
'success' => true,
'data' => $tokenData,
];
}
}

View File

@@ -241,6 +241,17 @@
'connection_failed' => 'AI 서버 연결에 실패했습니다.', 'connection_failed' => 'AI 서버 연결에 실패했습니다.',
], ],
// 가지급금 관리 관련
'loan' => [
'not_found' => '가지급금 정보를 찾을 수 없습니다.',
'not_editable' => '정산 완료된 가지급금은 수정할 수 없습니다.',
'not_deletable' => '정산 완료된 가지급금은 삭제할 수 없습니다.',
'not_settleable' => '이미 정산 완료된 가지급금입니다.',
'settlement_exceeds' => '정산금액이 가지급금액을 초과할 수 없습니다.',
'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.',
'user_not_found' => '직원 정보를 찾을 수 없습니다.',
],
// 내부 서버간 통신 관련 // 내부 서버간 통신 관련
'internal' => [ 'internal' => [
'secret_not_configured' => '내부 교환 비밀키가 설정되지 않았습니다.', 'secret_not_configured' => '내부 교환 비밀키가 설정되지 않았습니다.',

View File

@@ -300,4 +300,20 @@
'generated' => 'AI 리포트가 생성되었습니다.', 'generated' => 'AI 리포트가 생성되었습니다.',
'deleted' => 'AI 리포트가 삭제되었습니다.', 'deleted' => 'AI 리포트가 삭제되었습니다.',
], ],
// 가지급금 관리
'loan' => [
'fetched' => '가지급금을 조회했습니다.',
'created' => '가지급금이 등록되었습니다.',
'updated' => '가지급금이 수정되었습니다.',
'deleted' => '가지급금이 삭제되었습니다.',
'settled' => '가지급금이 정산되었습니다.',
'summary_fetched' => '가지급금 요약을 조회했습니다.',
'interest_calculated' => '인정이자가 계산되었습니다.',
'interest_report_fetched' => '인정이자 리포트를 조회했습니다.',
],
// 내부 서버간 통신
'internal' => [
'token_exchanged' => '토큰이 발급되었습니다.',
],
]; ];

View File

@@ -2,6 +2,7 @@
use App\Http\Controllers\Api\Admin\GlobalMenuController; use App\Http\Controllers\Api\Admin\GlobalMenuController;
use App\Http\Controllers\Api\V1\AdminController; use App\Http\Controllers\Api\V1\AdminController;
use App\Http\Controllers\Api\V1\AiReportController;
use App\Http\Controllers\Api\V1\ApiController; use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Controllers\Api\V1\ApprovalController; use App\Http\Controllers\Api\V1\ApprovalController;
use App\Http\Controllers\Api\V1\ApprovalFormController; use App\Http\Controllers\Api\V1\ApprovalFormController;
@@ -30,6 +31,7 @@
use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\EstimateController;
use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FileStorageController;
use App\Http\Controllers\Api\V1\FolderController; use App\Http\Controllers\Api\V1\FolderController;
use App\Http\Controllers\Api\V1\InternalController;
use App\Http\Controllers\Api\V1\ItemMaster\CustomTabController; use App\Http\Controllers\Api\V1\ItemMaster\CustomTabController;
use App\Http\Controllers\Api\V1\ItemMaster\EntityRelationshipController; use App\Http\Controllers\Api\V1\ItemMaster\EntityRelationshipController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController; use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController;
@@ -37,19 +39,20 @@
use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController; use App\Http\Controllers\Api\V1\ItemMaster\ItemMasterController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController; use App\Http\Controllers\Api\V1\ItemMaster\ItemPageController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController; use App\Http\Controllers\Api\V1\ItemMaster\ItemSectionController;
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController; use App\Http\Controllers\Api\V1\ItemMaster\SectionTemplateController;
use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController; use App\Http\Controllers\Api\V1\ItemMaster\UnitOptionController;
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\ItemsBomController; use App\Http\Controllers\Api\V1\ItemsBomController;
use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsController;
use App\Http\Controllers\Api\V1\ItemsFileController; use App\Http\Controllers\Api\V1\ItemsFileController;
use App\Http\Controllers\Api\V1\LeaveController; use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\LoanController;
use App\Http\Controllers\Api\V1\MenuController; use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController; use App\Http\Controllers\Api\V1\ModelSetController;
use App\Http\Controllers\Api\V1\PayrollController;
use App\Http\Controllers\Api\V1\PermissionController;
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\PayrollController;
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\PricingController; use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\PurchaseController; use App\Http\Controllers\Api\V1\PurchaseController;
@@ -58,23 +61,21 @@
use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RefreshController;
use App\Http\Controllers\Api\V1\RegisterController; use App\Http\Controllers\Api\V1\RegisterController;
use App\Http\Controllers\Api\V1\ReportController; use App\Http\Controllers\Api\V1\ReportController;
use App\Http\Controllers\Api\V1\AiReportController;
use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SaleController;
use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\SiteController;
use App\Http\Controllers\Api\V1\TenantController;
// 설계 전용 (디자인 네임스페이스) // 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantController;
use App\Http\Controllers\Api\V1\TenantFieldSettingController; use App\Http\Controllers\Api\V1\TenantFieldSettingController;
use App\Http\Controllers\Api\V1\TenantOptionGroupController; use App\Http\Controllers\Api\V1\TenantOptionGroupController;
use App\Http\Controllers\Api\V1\TenantOptionValueController; use App\Http\Controllers\Api\V1\TenantOptionValueController;
use App\Http\Controllers\Api\V1\TenantStatFieldController; use App\Http\Controllers\Api\V1\TenantStatFieldController;
use App\Http\Controllers\Api\V1\TenantUserProfileController;
// 모델셋 관리 (견적 시스템) // 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\TenantUserProfileController;
use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WithdrawalController;
use App\Http\Controllers\Api\V1\InternalController;
use App\Http\Controllers\Api\V1\WorkSettingController; use App\Http\Controllers\Api\V1\WorkSettingController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -391,6 +392,18 @@
Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip'); Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip');
}); });
// Loan API (가지급금 관리)
Route::prefix('loans')->group(function () {
Route::get('', [LoanController::class, 'index'])->name('v1.loans.index');
Route::post('', [LoanController::class, 'store'])->name('v1.loans.store');
Route::get('/summary', [LoanController::class, 'summary'])->name('v1.loans.summary');
Route::post('/calculate-interest', [LoanController::class, 'calculateInterest'])->name('v1.loans.calculate-interest');
Route::get('/{id}', [LoanController::class, 'show'])->whereNumber('id')->name('v1.loans.show');
Route::put('/{id}', [LoanController::class, 'update'])->whereNumber('id')->name('v1.loans.update');
Route::delete('/{id}', [LoanController::class, 'destroy'])->whereNumber('id')->name('v1.loans.destroy');
Route::post('/{id}/settle', [LoanController::class, 'settle'])->whereNumber('id')->name('v1.loans.settle');
});
// Sale API (매출 관리) // Sale API (매출 관리)
Route::prefix('sales')->group(function () { Route::prefix('sales')->group(function () {
Route::get('', [SaleController::class, 'index'])->name('v1.sales.index'); Route::get('', [SaleController::class, 'index'])->name('v1.sales.index');