Files
sam-api/app/Services/InternalTokenService.php
hskwon 8b30a555d2 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와 동일)
2025-12-18 14:21:37 +09:00

165 lines
5.0 KiB
PHP

<?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,
];
}
}