feat:E-Sign 서명 인증을 MNG 자체 API로 전환

기존: 브라우저 → 외부 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 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 16:26:28 +09:00
parent 9094a82f0a
commit 45e6afb862
6 changed files with 271 additions and 8 deletions

View File

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