$token]); } public function sign(string $token): View { return view('esign.sign.sign', ['token' => $token]); } 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; } }