From 45e6afb862286a299df3bb0389149d9c16fd06ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Feb 2026 16:26:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:E-Sign=20=EC=84=9C=EB=AA=85=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20MNG=20=EC=9E=90=EC=B2=B4=20API=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존: 브라우저 → 외부 API 서버(api.codebridge-x.com) 호출 변경: 브라우저 → MNG 자체 엔드포인트(/esign/sign/{token}/api/*) 호출 - EsignPublicController에 getContract/sendOtp/verifyOtp API 추가 - auth.blade.php에서 외부 API 호출을 MNG 자체 API로 변경 - EsignOtpMail Mailable + 이메일 템플릿 추가 - CSRF 예외에 esign/sign/*/api/* 추가 - 로컬/서버 환경 모두에서 동작 Co-Authored-By: Claude Opus 4.6 --- .../ESign/EsignPublicController.php | 170 ++++++++++++++++++ app/Mail/EsignOtpMail.php | 37 ++++ bootstrap/app.php | 1 + resources/views/emails/esign/otp.blade.php | 51 ++++++ resources/views/esign/sign/auth.blade.php | 14 +- routes/web.php | 6 + 6 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 app/Mail/EsignOtpMail.php create mode 100644 resources/views/emails/esign/otp.blade.php diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index 8415ca00..c6f5716b 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -3,11 +3,18 @@ namespace App\Http\Controllers\ESign; use App\Http\Controllers\Controller; +use App\Models\ESign\EsignAuditLog; +use App\Models\ESign\EsignContract; +use App\Models\ESign\EsignSigner; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Illuminate\View\View; class EsignPublicController extends Controller { + // ─── 화면 라우트 ─── + public function auth(string $token): View { return view('esign.sign.auth', ['token' => $token]); @@ -22,4 +29,167 @@ public function done(string $token): View { return view('esign.sign.done', ['token' => $token]); } + + // ─── 서명 API (토큰 기반, 비인증) ─── + + /** + * 토큰으로 계약/서명자 정보 조회 + */ + public function getContract(string $token): JsonResponse + { + $signer = EsignSigner::withoutGlobalScopes() + ->where('access_token', $token) + ->first(); + + if (! $signer) { + return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); + } + + if ($signer->token_expires_at && $signer->token_expires_at->isPast()) { + return response()->json(['success' => false, 'message' => '서명 링크가 만료되었습니다.'], 400); + } + + $contract = EsignContract::withoutGlobalScopes() + ->with(['signers:id,contract_id,name,role,status,signed_at']) + ->find($signer->contract_id); + + if (! $contract) { + return response()->json(['success' => false, 'message' => '계약을 찾을 수 없습니다.'], 404); + } + + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => 'viewed', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'data' => [ + 'contract' => $contract, + 'signer' => [ + 'id' => $signer->id, + 'name' => $signer->name, + 'email' => $signer->email, + 'role' => $signer->role, + 'status' => $signer->status, + ], + ], + ]); + } + + /** + * OTP 발송 + */ + public function sendOtp(string $token): JsonResponse + { + $signer = $this->findSigner($token); + if (! $signer) { + return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); + } + + $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); + if (! $contract || ! in_array($contract->status, ['pending', 'partially_signed'])) { + return response()->json(['success' => false, 'message' => '서명할 수 없는 상태입니다.'], 400); + } + + $otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); + + $signer->update([ + 'otp_code' => $otpCode, + 'otp_expires_at' => now()->addMinutes(5), + 'otp_attempts' => 0, + ]); + + // OTP 이메일 발송 + \Illuminate\Support\Facades\Mail::to($signer->email)->send( + new \App\Mail\EsignOtpMail($signer->name, $otpCode) + ); + + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => 'otp_sent', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'metadata' => ['email' => $signer->email], + 'created_at' => now(), + ]); + + return response()->json(['success' => true, 'message' => '인증 코드가 발송되었습니다.']); + } + + /** + * OTP 검증 + */ + public function verifyOtp(Request $request, string $token): JsonResponse + { + $signer = $this->findSigner($token); + if (! $signer) { + return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); + } + + if ($signer->otp_attempts >= 5) { + return response()->json(['success' => false, 'message' => '인증 시도 횟수를 초과했습니다.'], 400); + } + + if (! $signer->otp_code || ! $signer->otp_expires_at) { + return response()->json(['success' => false, 'message' => '인증 코드가 발송되지 않았습니다.'], 400); + } + + if ($signer->otp_expires_at->isPast()) { + return response()->json(['success' => false, 'message' => '인증 코드가 만료되었습니다.'], 400); + } + + $signer->increment('otp_attempts'); + + $otpCode = $request->input('otp_code'); + if ($signer->otp_code !== $otpCode) { + return response()->json(['success' => false, 'message' => '인증 코드가 올바르지 않습니다.'], 400); + } + + $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); + + $signer->update([ + 'auth_verified_at' => now(), + 'otp_code' => null, + 'otp_expires_at' => null, + 'status' => 'authenticated', + ]); + + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => 'authenticated', + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'data' => ['sign_session_token' => Str::random(64)], + ]); + } + + // ─── Private ─── + + private function findSigner(string $token): ?EsignSigner + { + $signer = EsignSigner::withoutGlobalScopes() + ->where('access_token', $token) + ->first(); + + if ($signer && $signer->token_expires_at && $signer->token_expires_at->isPast()) { + return null; + } + + return $signer; + } } diff --git a/app/Mail/EsignOtpMail.php b/app/Mail/EsignOtpMail.php new file mode 100644 index 00000000..194b2de6 --- /dev/null +++ b/app/Mail/EsignOtpMail.php @@ -0,0 +1,37 @@ + $this->signerName, + 'otpCode' => $this->otpCode, + ], + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ff80ee5e..5792328c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -26,6 +26,7 @@ 'menu-sync/*', 'common-code-sync/*', 'category-sync/*', + 'esign/sign/*/api/*', ]); // web 미들웨어 그룹에 자동 재인증 추가 diff --git a/resources/views/emails/esign/otp.blade.php b/resources/views/emails/esign/otp.blade.php new file mode 100644 index 00000000..1cdf26be --- /dev/null +++ b/resources/views/emails/esign/otp.blade.php @@ -0,0 +1,51 @@ + + + + + + 인증 코드 + + + + + + +
+ + + + + + + + + + +
+

인증 코드

+
+

안녕하세요, {{ $signerName }}님.

+

+ 전자계약 서명을 위한 인증 코드입니다.
+ 아래 코드를 입력해 주세요. +

+ + + + +
+
+ {{ $otpCode }} +
+
+

+ 이 코드는 5분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시해 주세요. +

+
+

+ 본 메일은 SAM 전자계약 시스템에서 자동 발송되었습니다. +

+
+
+ + diff --git a/resources/views/esign/sign/auth.blade.php b/resources/views/esign/sign/auth.blade.php index 81b50313..83b63262 100644 --- a/resources/views/esign/sign/auth.blade.php +++ b/resources/views/esign/sign/auth.blade.php @@ -19,8 +19,6 @@ const { useState, useEffect, useCallback } = React; const TOKEN = document.getElementById('esign-auth-root')?.dataset.token; -const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}'; -const API_KEY = '{{ config("services.api.key", "") }}'; const App = () => { const [contract, setContract] = useState(null); @@ -33,8 +31,8 @@ const fetchContract = useCallback(async () => { try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, { - headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY }, + const res = await fetch(`/esign/sign/${TOKEN}/api/contract`, { + headers: { 'Accept': 'application/json' }, }); const json = await res.json(); if (json.success) { @@ -55,9 +53,9 @@ setOtpSending(true); setError(''); try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/send`, { + const res = await fetch(`/esign/sign/${TOKEN}/api/otp/send`, { method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, }); const json = await res.json(); if (json.success) { @@ -74,9 +72,9 @@ setVerifying(true); setError(''); try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/verify`, { + const res = await fetch(`/esign/sign/${TOKEN}/api/otp/verify`, { method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ otp_code: otp }), }); const json = await res.json(); diff --git a/routes/web.php b/routes/web.php index 6e254d2e..82bf7843 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1422,7 +1422,13 @@ |-------------------------------------------------------------------------- */ Route::prefix('esign/sign')->group(function () { + // 화면 라우트 Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth'); Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do'); Route::get('/{token}/done', [EsignPublicController::class, 'done'])->name('esign.sign.done'); + + // 서명 API (토큰 기반, 비인증) + Route::get('/{token}/api/contract', [EsignPublicController::class, 'getContract'])->name('esign.sign.api.contract'); + Route::post('/{token}/api/otp/send', [EsignPublicController::class, 'sendOtp'])->name('esign.sign.api.otp.send'); + Route::post('/{token}/api/otp/verify', [EsignPublicController::class, 'verifyOtp'])->name('esign.sign.api.otp.verify'); });