diff --git a/app/Http/Controllers/Api/V1/InternalController.php b/app/Http/Controllers/Api/V1/InternalController.php new file mode 100644 index 0000000..c4ba77b --- /dev/null +++ b/app/Http/Controllers/Api/V1/InternalController.php @@ -0,0 +1,46 @@ +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') + ); + } +} diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index b7565e5..920190a 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -21,6 +21,7 @@ public function handle(Request $request, Closure $next) 'api/v1/register', 'api/v1/refresh', 'api/v1/debug-apikey', + 'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용) 'api-docs', // Swagger UI 'api-docs/*', // Swagger 하위 경로 'docs', // L5-Swagger UI @@ -120,6 +121,7 @@ public function handle(Request $request, Closure $next) 'api/v1/register', 'api/v1/refresh', 'api/v1/debug-apikey', + 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) // 추가적으로 허용하고 싶은 라우트 ]; diff --git a/app/Http/Requests/Internal/ExchangeTokenRequest.php b/app/Http/Requests/Internal/ExchangeTokenRequest.php new file mode 100644 index 0000000..d0874d9 --- /dev/null +++ b/app/Http/Requests/Internal/ExchangeTokenRequest.php @@ -0,0 +1,53 @@ +|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 + */ + 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]), + ]; + } +} diff --git a/app/Services/InternalTokenService.php b/app/Services/InternalTokenService.php new file mode 100644 index 0000000..8b25050 --- /dev/null +++ b/app/Services/InternalTokenService.php @@ -0,0 +1,164 @@ + 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, + ]; + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 2d87e27..607ba6b 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -241,6 +241,17 @@ 'connection_failed' => 'AI 서버 연결에 실패했습니다.', ], + // 가지급금 관리 관련 + 'loan' => [ + 'not_found' => '가지급금 정보를 찾을 수 없습니다.', + 'not_editable' => '정산 완료된 가지급금은 수정할 수 없습니다.', + 'not_deletable' => '정산 완료된 가지급금은 삭제할 수 없습니다.', + 'not_settleable' => '이미 정산 완료된 가지급금입니다.', + 'settlement_exceeds' => '정산금액이 가지급금액을 초과할 수 없습니다.', + 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', + 'user_not_found' => '직원 정보를 찾을 수 없습니다.', + ], + // 내부 서버간 통신 관련 'internal' => [ 'secret_not_configured' => '내부 교환 비밀키가 설정되지 않았습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 556ae2a..ce9c9eb 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -300,4 +300,20 @@ 'generated' => 'AI 리포트가 생성되었습니다.', 'deleted' => 'AI 리포트가 삭제되었습니다.', ], + // 가지급금 관리 + 'loan' => [ + 'fetched' => '가지급금을 조회했습니다.', + 'created' => '가지급금이 등록되었습니다.', + 'updated' => '가지급금이 수정되었습니다.', + 'deleted' => '가지급금이 삭제되었습니다.', + 'settled' => '가지급금이 정산되었습니다.', + 'summary_fetched' => '가지급금 요약을 조회했습니다.', + 'interest_calculated' => '인정이자가 계산되었습니다.', + 'interest_report_fetched' => '인정이자 리포트를 조회했습니다.', + ], + + // 내부 서버간 통신 + 'internal' => [ + 'token_exchanged' => '토큰이 발급되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 421c5de..a482796 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\Admin\GlobalMenuController; 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\ApprovalController; use App\Http\Controllers\Api\V1\ApprovalFormController; @@ -30,6 +31,7 @@ use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\FileStorageController; 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\EntityRelationshipController; 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\ItemPageController; 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\UnitOptionController; -// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨 use App\Http\Controllers\Api\V1\ItemsBomController; use App\Http\Controllers\Api\V1\ItemsController; use App\Http\Controllers\Api\V1\ItemsFileController; 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\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\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\PricingController; use App\Http\Controllers\Api\V1\PurchaseController; @@ -58,23 +61,21 @@ use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RegisterController; 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\RolePermissionController; use App\Http\Controllers\Api\V1\SaleController; 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\TenantOptionGroupController; use App\Http\Controllers\Api\V1\TenantOptionValueController; 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\UserRoleController; use App\Http\Controllers\Api\V1\WithdrawalController; -use App\Http\Controllers\Api\V1\InternalController; use App\Http\Controllers\Api\V1\WorkSettingController; use Illuminate\Support\Facades\Route; @@ -391,6 +392,18 @@ 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 (매출 관리) Route::prefix('sales')->group(function () { Route::get('', [SaleController::class, 'index'])->name('v1.sales.index');