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:
@@ -3,11 +3,18 @@
|
|||||||
namespace App\Http\Controllers\ESign;
|
namespace App\Http\Controllers\ESign;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class EsignPublicController extends Controller
|
class EsignPublicController extends Controller
|
||||||
{
|
{
|
||||||
|
// ─── 화면 라우트 ───
|
||||||
|
|
||||||
public function auth(string $token): View
|
public function auth(string $token): View
|
||||||
{
|
{
|
||||||
return view('esign.sign.auth', ['token' => $token]);
|
return view('esign.sign.auth', ['token' => $token]);
|
||||||
@@ -22,4 +29,167 @@ public function done(string $token): View
|
|||||||
{
|
{
|
||||||
return view('esign.sign.done', ['token' => $token]);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Mail/EsignOtpMail.php
Normal file
37
app/Mail/EsignOtpMail.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class EsignOtpMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $signerName,
|
||||||
|
public string $otpCode,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: '[SAM] 전자계약 인증 코드',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.esign.otp',
|
||||||
|
with: [
|
||||||
|
'signerName' => $this->signerName,
|
||||||
|
'otpCode' => $this->otpCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
'menu-sync/*',
|
'menu-sync/*',
|
||||||
'common-code-sync/*',
|
'common-code-sync/*',
|
||||||
'category-sync/*',
|
'category-sync/*',
|
||||||
|
'esign/sign/*/api/*',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// web 미들웨어 그룹에 자동 재인증 추가
|
// web 미들웨어 그룹에 자동 재인증 추가
|
||||||
|
|||||||
51
resources/views/emails/esign/otp.blade.php
Normal file
51
resources/views/emails/esign/otp.blade.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>인증 코드</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #2563eb; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">인증 코드</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<p style="margin: 0 0 16px; font-size: 16px; color: #333;">안녕하세요, <strong>{{ $signerName }}</strong>님.</p>
|
||||||
|
<p style="margin: 0 0 24px; font-size: 15px; color: #555; line-height: 1.6;">
|
||||||
|
전자계약 서명을 위한 인증 코드입니다.<br>
|
||||||
|
아래 코드를 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 16px 0;">
|
||||||
|
<div style="display: inline-block; background-color: #f8fafc; border: 2px solid #e5e7eb; border-radius: 8px; padding: 20px 40px;">
|
||||||
|
<span style="font-size: 36px; font-weight: 700; letter-spacing: 12px; color: #2563eb;">{{ $otpCode }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin: 24px 0 0; font-size: 13px; color: #999; line-height: 1.6;">
|
||||||
|
이 코드는 5분간 유효합니다. 본인이 요청하지 않았다면 이 메일을 무시해 주세요.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #999;">
|
||||||
|
본 메일은 SAM 전자계약 시스템에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,8 +19,6 @@
|
|||||||
const { useState, useEffect, useCallback } = React;
|
const { useState, useEffect, useCallback } = React;
|
||||||
|
|
||||||
const TOKEN = document.getElementById('esign-auth-root')?.dataset.token;
|
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 App = () => {
|
||||||
const [contract, setContract] = useState(null);
|
const [contract, setContract] = useState(null);
|
||||||
@@ -33,8 +31,8 @@
|
|||||||
|
|
||||||
const fetchContract = useCallback(async () => {
|
const fetchContract = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, {
|
const res = await fetch(`/esign/sign/${TOKEN}/api/contract`, {
|
||||||
headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY },
|
headers: { 'Accept': 'application/json' },
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
@@ -55,9 +53,9 @@
|
|||||||
setOtpSending(true);
|
setOtpSending(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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',
|
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();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
@@ -74,9 +72,9 @@
|
|||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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',
|
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 }),
|
body: JSON.stringify({ otp_code: otp }),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|||||||
@@ -1422,7 +1422,13 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
Route::prefix('esign/sign')->group(function () {
|
Route::prefix('esign/sign')->group(function () {
|
||||||
|
// 화면 라우트
|
||||||
Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth');
|
Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth');
|
||||||
Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do');
|
Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do');
|
||||||
Route::get('/{token}/done', [EsignPublicController::class, 'done'])->name('esign.sign.done');
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user