# SAM E-Sign 구현 가이드 > **프로젝트명**: SAM E-Sign (전자계약 서명 솔루션) > **구현일**: 2026-02-12 > **버전**: v1.0 > **설계 문서**: [technical-design.md](./technical-design.md) --- ## 1. 구현 개요 모두싸인과 유사한 간편 전자계약 서명 솔루션을 SAM 시스템에 구현했다. API 프로젝트에 백엔드 로직(모델, 서비스, 컨트롤러, 라우트)을, MNG 프로젝트에 프론트엔드(컨트롤러, React 뷰)를 구축했다. ### 구현 범위 | 영역 | 내용 | |------|------| | 데이터베이스 | 마이그레이션 4개 (esign_ 접두사 테이블) | | 모델 | 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog) | | API 서비스 | 4개 (Contract, Sign, Pdf, Audit) | | MNG 서비스 | 2개 (DocxToPdfConverter, PdfSignatureService) | | API 컨트롤러 | 2개 (Contract 10엔드포인트, Sign 6엔드포인트) | | FormRequest | 4개 (Store, FieldConfigure, SignSubmit, SignReject) | | 메일 | 1개 (EsignRequestMail) | | MNG 컨트롤러 | 2개 (인증 5화면, 공개 3화면) | | MNG 뷰 | 8개 (React 하이브리드) | | API 라우트 | 16개 | | MNG 라우트 | 8개 | | i18n | message 12키 + error 16키 | **총 파일**: 신규 29개 + 수정 4개 = 33개 --- ## 2. 파일 구조 ### API 프로젝트 (`/home/aweso/sam/api`) ``` database/migrations/ ├── 2026_02_12_100000_create_esign_contracts_table.php ├── 2026_02_12_110000_create_esign_signers_table.php ├── 2026_02_12_120000_create_esign_sign_fields_table.php └── 2026_02_12_130000_create_esign_audit_logs_table.php app/Models/ESign/ ├── EsignContract.php ├── EsignSigner.php ├── EsignSignField.php └── EsignAuditLog.php app/Services/ESign/ ├── EsignContractService.php ├── EsignSignService.php ├── EsignPdfService.php └── EsignAuditService.php app/Http/Controllers/Api/V1/ESign/ ├── EsignContractController.php └── EsignSignController.php app/Http/Requests/ESign/ ├── ContractStoreRequest.php ├── FieldConfigureRequest.php ├── SignSubmitRequest.php └── SignRejectRequest.php app/Mail/ └── EsignRequestMail.php resources/views/emails/esign/ └── request.blade.php routes/api/v1/ └── esign.php routes/api.php # 수정 (esign.php include 추가) lang/ko/message.php # 수정 (esign 키 추가) lang/ko/error.php # 수정 (esign 키 추가) ``` ### MNG 프로젝트 (`/home/aweso/sam/mng`) ``` app/Http/Controllers/ESign/ ├── EsignController.php # 인증 필요 (5개 메서드) └── EsignPublicController.php # 비인증 (3개 메서드) app/Services/ESign/ ├── DocxToPdfConverter.php # DOCX→PDF 변환 (LibreOffice headless) └── PdfSignatureService.php # PDF 서명 합성 (FPDI/TCPDF) resources/views/esign/ ├── dashboard.blade.php # 대시보드 (통계 + 목록) ├── create.blade.php # 계약 생성 (PDF 업로드) ├── detail.blade.php # 계약 상세 (현황 + 로그) ├── fields.blade.php # 서명 위치 지정 (PDF.js) ├── send.blade.php # 서명 요청 발송 확인 └── sign/ ├── auth.blade.php # 공개 - OTP 본인인증 ├── sign.blade.php # 공개 - 서명 수행 └── done.blade.php # 공개 - 서명 완료 routes/web.php # 수정 (esign 라우트 추가) ``` --- ## 3. 데이터베이스 스키마 ### 3.1 esign_contracts (계약 마스터) | 컬럼 | 타입 | 설명 | |------|------|------| | id | BIGINT PK | 자동 증가 | | tenant_id | BIGINT FK | 테넌트 | | contract_code | VARCHAR(50) UNIQUE | 계약 코드 (ES-YYYYMMDD-RANDOM) | | title | VARCHAR(200) | 계약 제목 | | description | TEXT NULL | 계약 설명 | | sign_order_type | ENUM | counterpart_first, creator_first | | original_file_path | VARCHAR(500) | 원본 PDF 경로 | | original_file_name | VARCHAR(255) | 원본 파일명 | | original_file_hash | VARCHAR(64) | SHA-256 해시 | | original_file_size | INT UNSIGNED | 파일 크기 (bytes) | | signed_file_path | VARCHAR(500) NULL | 서명 완료 PDF 경로 | | signed_file_hash | VARCHAR(64) NULL | 서명 완료 PDF 해시 | | status | ENUM | draft/pending/partially_signed/completed/expired/cancelled/rejected | | expires_at | TIMESTAMP | 서명 기한 | | completed_at | TIMESTAMP NULL | 완료 시각 | | created_by/updated_by/deleted_by | BIGINT NULL | 감사 필드 | | timestamps, softDeletes | | | **인덱스**: tenant_id+status, contract_code(unique), expires_at ### 3.2 esign_signers (서명자) | 컬럼 | 타입 | 설명 | |------|------|------| | id | BIGINT PK | | | tenant_id | BIGINT FK | 테넌트 | | contract_id | BIGINT FK | → esign_contracts | | role | ENUM | creator, counterpart | | sign_order | TINYINT | 서명 순서 (1 또는 2) | | name | VARCHAR(100) | 서명자 이름 | | email | VARCHAR(255) | 이메일 | | phone | VARCHAR(20) NULL | 전화번호 | | access_token | VARCHAR(128) UNIQUE | 서명 링크 토큰 | | token_expires_at | TIMESTAMP | 토큰 만료 | | otp_code | VARCHAR(10) NULL | OTP 코드 | | otp_expires_at | TIMESTAMP NULL | OTP 만료 | | otp_attempts | TINYINT DEFAULT 0 | OTP 시도 횟수 | | auth_verified_at | TIMESTAMP NULL | 본인인증 완료 | | signature_image_path | VARCHAR(500) NULL | 서명 이미지 경로 | | signed_at | TIMESTAMP NULL | 서명 시각 | | consent_agreed_at | TIMESTAMP NULL | 동의 시각 | | sign_ip_address | VARCHAR(45) NULL | 서명 IP | | sign_user_agent | TEXT NULL | User-Agent | | status | ENUM | waiting/notified/authenticated/signed/rejected | | rejected_reason | TEXT NULL | 거절 사유 | ### 3.3 esign_sign_fields (서명 필드) | 컬럼 | 타입 | 설명 | |------|------|------| | id | BIGINT PK | | | tenant_id | BIGINT FK | 테넌트 | | contract_id | BIGINT FK | → esign_contracts | | signer_id | BIGINT FK | → esign_signers | | page_number | INT UNSIGNED | PDF 페이지 번호 | | position_x | DECIMAL(8,4) | X 좌표 (%) | | position_y | DECIMAL(8,4) | Y 좌표 (%) | | width | DECIMAL(8,4) | 너비 (%) | | height | DECIMAL(8,4) | 높이 (%) | | field_type | ENUM | signature/stamp/text/date/checkbox | | field_label | VARCHAR(100) NULL | 필드 라벨 | | field_value | TEXT NULL | 입력된 값 | | is_required | BOOLEAN DEFAULT true | 필수 여부 | | sort_order | INT DEFAULT 0 | 정렬 순서 | ### 3.4 esign_audit_logs (감사 로그) | 컬럼 | 타입 | 설명 | |------|------|------| | id | BIGINT PK | | | tenant_id | BIGINT FK | 테넌트 | | contract_id | BIGINT FK | → esign_contracts | | signer_id | BIGINT FK NULL | → esign_signers (NULL = 시스템 이벤트) | | action | VARCHAR(50) | 액션 코드 | | ip_address | VARCHAR(45) NULL | IP 주소 | | user_agent | TEXT NULL | User-Agent | | metadata | JSON NULL | 추가 데이터 | | created_at | TIMESTAMP | 생성 시각 (삭제 불가) | **감사 로그 액션 코드**: | 코드 | 설명 | |------|------| | created | 계약 생성 | | sent | 서명 요청 발송 | | viewed | 문서 열람 | | otp_sent | OTP 발송 | | authenticated | 본인인증 완료 | | signed | 서명 완료 | | rejected | 서명 거절 | | completed | 계약 완료 | | cancelled | 계약 취소 | | reminded | 리마인더 발송 | | downloaded | 문서 다운로드 | ### ER 다이어그램 ``` esign_contracts (1) ──── (N) esign_signers │ │ (1) (1) │ │ (N) (N) esign_sign_fields esign_audit_logs ``` --- ## 4. 모델 계층 ### 4.1 EsignContract **파일**: `app/Models/ESign/EsignContract.php` ```php use Auditable, BelongsToTenant, SoftDeletes; // 상수 STATUS_DRAFT, STATUS_PENDING, STATUS_PARTIALLY_SIGNED, STATUS_COMPLETED, STATUS_EXPIRED, STATUS_CANCELLED, STATUS_REJECTED SIGN_ORDER_COUNTERPART_FIRST, SIGN_ORDER_CREATOR_FIRST // 관계 signers() → HasMany(EsignSigner) signFields() → HasMany(EsignSignField) auditLogs() → HasMany(EsignAuditLog) creator() → BelongsTo(User, 'created_by') // 스코프 scopeStatus($query, $status) scopeActive($query) // pending, partially_signed // 헬퍼 isExpired(): bool // expires_at < now canSign(): bool // pending 또는 partially_signed + 미만료 getNextSigner(): ?EsignSigner // sign_order 기준 다음 서명자 ``` ### 4.2 EsignSigner **파일**: `app/Models/ESign/EsignSigner.php` ```php use BelongsToTenant; // 상수 ROLE_CREATOR, ROLE_COUNTERPART STATUS_WAITING, STATUS_NOTIFIED, STATUS_AUTHENTICATED, STATUS_SIGNED, STATUS_REJECTED // Hidden 필드: access_token, otp_code // 관계 contract() → BelongsTo(EsignContract) signFields() → HasMany(EsignSignField) // 헬퍼 isVerified(): bool // auth_verified_at !== null hasSigned(): bool // signed_at !== null canSign(): bool // !hasSigned + !rejected + contract.canSign ``` ### 4.3 EsignSignField **파일**: `app/Models/ESign/EsignSignField.php` ```php use BelongsToTenant; // 상수 TYPE_SIGNATURE, TYPE_STAMP, TYPE_TEXT, TYPE_DATE, TYPE_CHECKBOX // 관계 contract() → BelongsTo(EsignContract) signer() → BelongsTo(EsignSigner) ``` ### 4.4 EsignAuditLog **파일**: `app/Models/ESign/EsignAuditLog.php` ```php use BelongsToTenant; $timestamps = false; // created_at만 사용 $casts = ['metadata' => 'array']; // 상수 ACTION_CREATED, ACTION_SENT, ACTION_VIEWED, ACTION_OTP_SENT, ACTION_AUTHENTICATED, ACTION_SIGNED, ACTION_REJECTED, ACTION_COMPLETED, ACTION_CANCELLED, ACTION_REMINDED, ACTION_DOWNLOADED // 관계 contract() → BelongsTo(EsignContract) signer() → BelongsTo(EsignSigner) ``` --- ## 5. 서비스 계층 모든 서비스는 `App\Services\Service`를 상속하며, `tenantId()`와 `apiUserId()`를 사용한다. ### 5.1 EsignContractService **파일**: `app/Services/ESign/EsignContractService.php` **의존성**: EsignAuditService, EsignPdfService | 메서드 | 설명 | |--------|------| | `list(array $params)` | 필터/페이지네이션 목록 (status, search, date_from/to, per_page) | | `stats()` | 상태별 통계 (GROUP BY status) | | `create(array $data)` | 계약 생성 + PDF 저장 + 해시 + 서명자 2인 생성 | | `show(int $id)` | 상세 조회 (signers, signFields, auditLogs eager loading) | | `cancel(int $id)` | 계약 취소 (draft/pending만 가능) | | `send(int $id)` | 서명 요청 발송 (상태 → pending, 이메일 발송) | | `remind(int $id)` | 리마인더 발송 (다음 서명자에게 이메일 재발송) | | `configureFields(int $id, array $fields)` | 서명 위치 설정 (기존 삭제 후 재생성, draft만) | **계약 코드 생성 규칙**: `ES-YYYYMMDD-RANDOM6` (예: ES-20260212-A3F2K9) ### 5.2 EsignSignService **파일**: `app/Services/ESign/EsignSignService.php` **의존성**: EsignAuditService, EsignPdfService **특이사항**: 모든 쿼리에서 `withoutGlobalScopes()` 사용 (토큰 기반 공개 접근) | 메서드 | 설명 | |--------|------| | `getByToken(string $token)` | 토큰으로 계약+서명자 조회 (만료/상태 검증) | | `sendOtp(string $token)` | 6자리 OTP 생성 + 5분 만료 + 이메일 발송 | | `verifyOtp(string $token, string $otpCode)` | OTP 검증 (최대 5회) → sign_session_token JWT 발급 | | `submitSignature(string $token, array $data)` | 서명 이미지 저장 + 자동 완료 체크 | | `reject(string $token, string $reason)` | 서명 거절 → 계약 상태 rejected | | `checkAndComplete(EsignContract)` | (private) 양쪽 서명 완료 시 자동 completed 처리 | **OTP 보안 규칙**: - 6자리 숫자 - 5분 유효 (`otp_expires_at`) - 최대 5회 시도 (`otp_attempts`) - 초과 시 토큰 무효화 ### 5.3 EsignPdfService (API) **파일**: `api/app/Services/ESign/EsignPdfService.php` | 메서드 | 설명 | 상태 | |--------|------|------| | `generateHash(string $filePath)` | SHA-256 해시 생성 | 구현 완료 | | `verifyIntegrity(string $filePath, string $expectedHash)` | 해시 비교 검증 (hash_equals) | 구현 완료 | | `composeSigned(...)` | 원본 PDF + 서명 이미지 합성 | 스텁 (MNG로 이관) | | `addAuditPage(...)` | 감사 증적 페이지 추가 | 스텁 (추후) | ### 5.5 DocxToPdfConverter (MNG) **파일**: `mng/app/Services/ESign/DocxToPdfConverter.php` **의존성**: LibreOffice (headless), 나눔 폰트 | 메서드 | 설명 | 상태 | |--------|------|------| | `convertAndStore(UploadedFile $file)` | DOCX/DOC 파일을 LibreOffice로 PDF 변환 후 저장 | 구현 완료 | **동작 방식**: - Word 파일(.doc, .docx) 업로드 시 자동 감지 - `libreoffice --headless --convert-to pdf` 명령으로 변환 - 나눔 폰트로 한글 정상 렌더링 지원 - 변환된 PDF를 `storage/app/private/esign/{tenant_id}/originals/` 에 저장 ### 5.6 PdfSignatureService (MNG) **파일**: `mng/app/Services/ESign/PdfSignatureService.php` **의존성**: FPDI, TCPDF, GD 확장 | 메서드 | 설명 | 상태 | |--------|------|------| | `mergeSignatures(EsignContract $contract)` | 원본 PDF에 모든 서명 이미지 오버레이 합성 | 구현 완료 | **동작 방식**: - FPDI로 원본 PDF 임포트 - 서명 필드를 페이지별로 그룹핑 - 필드 타입별 렌더링: signature/stamp(이미지), date(텍스트), text(텍스트), checkbox(체크마크) - 서명된 PDF를 `storage/app/private/esign/{tenant_id}/signed/` 에 저장 ### 5.4 EsignAuditService **파일**: `app/Services/ESign/EsignAuditService.php` | 메서드 | 설명 | |--------|------| | `log(int $contractId, string $action, ...)` | 감사 로그 기록 (인증 컨텍스트) | | `logPublic(int $tenantId, int $contractId, ...)` | 감사 로그 기록 (공개 접근, withoutGlobalScopes) | | `getContractLogs(int $contractId)` | 계약별 감사 로그 조회 (signer 포함) | --- ## 6. API 엔드포인트 ### 6.1 계약 관리 API (인증 필요) 미들웨어: `auth.apikey` | Method | Path | Controller | 설명 | |--------|------|-----------|------| | GET | `/api/v1/esign/contracts` | index | 계약 목록 (필터/페이지네이션) | | POST | `/api/v1/esign/contracts` | store | 계약 생성 (multipart/form-data) | | GET | `/api/v1/esign/contracts/stats` | stats | 상태별 통계 | | GET | `/api/v1/esign/contracts/{id}` | show | 계약 상세 | | POST | `/api/v1/esign/contracts/{id}/cancel` | cancel | 계약 취소 | | POST | `/api/v1/esign/contracts/{id}/fields` | configureFields | 서명 위치 설정 | | POST | `/api/v1/esign/contracts/{id}/send` | send | 서명 요청 발송 | | POST | `/api/v1/esign/contracts/{id}/remind` | remind | 리마인더 발송 | | GET | `/api/v1/esign/contracts/{id}/download` | download | PDF 다운로드 | | GET | `/api/v1/esign/contracts/{id}/verify` | verify | 무결성 검증 | ### 6.2 서명 수행 API (토큰 기반, 비인증) 미들웨어: API 키만 필요 (테넌트 인증 없음) | Method | Path | Controller | 설명 | |--------|------|-----------|------| | GET | `/api/v1/esign/sign/{token}` | getContract | 계약 정보 조회 | | POST | `/api/v1/esign/sign/{token}/otp/send` | sendOtp | OTP 발송 | | POST | `/api/v1/esign/sign/{token}/otp/verify` | verifyOtp | OTP 검증 | | GET | `/api/v1/esign/sign/{token}/document` | getDocument | PDF 스트리밍 | | POST | `/api/v1/esign/sign/{token}/submit` | submit | 서명 제출 | | POST | `/api/v1/esign/sign/{token}/reject` | reject | 서명 거절 | ### 6.3 응답 패턴 모든 API는 `ApiResponse::handle()` 패턴을 따른다: ```json // 성공 { "success": true, "message": "처리 완료", "data": { ... } } // 실패 { "success": false, "message": "오류 메시지", "error": { ... } } ``` --- ## 7. MNG 화면 구성 ### 7.1 라우트 구조 **인증 필요** (미들웨어: auth): | 경로 | 컨트롤러 | 뷰 | |------|---------|-----| | GET `/esign` | EsignController@dashboard | esign/dashboard | | GET `/esign/create` | EsignController@create | esign/create | | GET `/esign/{id}` | EsignController@detail | esign/detail | | GET `/esign/{id}/fields` | EsignController@fields | esign/fields | | GET `/esign/{id}/send` | EsignController@send | esign/send | **공개** (비인증): | 경로 | 컨트롤러 | 뷰 | |------|---------|-----| | GET `/esign/sign/{token}` | EsignPublicController@auth | esign/sign/auth | | GET `/esign/sign/{token}/sign` | EsignPublicController@sign | esign/sign/sign | | GET `/esign/sign/{token}/done` | EsignPublicController@done | esign/sign/done | ### 7.2 뷰 기술 스택 | 뷰 | 레이아웃 | 기술 | |-----|---------|------| | dashboard | layouts.app | React 18 + Babel | | create | layouts.app | React 18 + Babel | | detail | layouts.app | React 18 + Babel | | fields | layouts.app | React 18 + PDF.js + Babel | | send | layouts.app | React 18 + Babel | | sign/auth | Standalone HTML | React 18 + Babel | | sign/sign | Standalone HTML | React 18 + SignaturePad + Babel | | sign/done | Standalone HTML | React 18 + Babel | **공통 패턴**: - 인증 화면: `@extends('layouts.app')` + HX-Redirect 패턴 - 공개 화면: 독립 HTML (layouts.app 미사용, Tailwind CSS만 로드) - API 호출: `window.SAM_CONFIG?.apiBaseUrl` 또는 `config('services.api.base_url')` - 인증 토큰: `sessionStorage.getItem('api_access_token')` (인증), `esign_session_token` (공개) ### 7.3 화면별 기능 **대시보드** (`dashboard.blade.php`): - 상태별 통계 카드 (전체/진행중/대기/완료/만료) - 상태 필터, 검색, 날짜 범위 필터 - 계약 목록 테이블 (페이지네이션) - 상태 배지 (색상 구분) **계약 생성** (`create.blade.php`): - 계약 정보 입력 (제목, 설명, 서명 순서, 기한) - PDF 파일 업로드 (드래그&드롭) - 작성자 정보, 상대방 정보 입력 - multipart/form-data 제출 **계약 상세** (`detail.blade.php`): - 계약 기본 정보 - 서명자 현황 (작성자/상대방 상태, 서명 시각) - 감사 로그 타임라인 - 액션 버튼 (발송, 리마인더, 취소, 다운로드, 검증) **서명 위치 지정** (`fields.blade.php`): - PDF.js로 PDF 렌더링 - 페이지 네비게이션 - 서명자별 필드 추가 (클릭으로 위치 지정) - 필드 드래그/리사이즈 - 필드 타입 설정 (서명/도장/텍스트/날짜/체크박스) - 좌표값 (% 기반) **서명 요청 발송** (`send.blade.php`): - 발송 전 체크리스트 (서명 필드 설정 여부) - 서명 순서 확인 - 최종 발송 버튼 **본인인증** (`sign/auth.blade.php`): - 계약 정보 표시 (제목, 서명자, 기한) - OTP 발송 버튼 - 6자리 OTP 입력 폼 - 재발송 기능 **서명 수행** (`sign/sign.blade.php`): - 3단계: 문서 확인 → 서명 입력 → 서명 확인 - 문서 확인: PDF 다운로드 링크 + 동의 체크박스 - 서명 입력: SignaturePad 캔버스 (터치/마우스) - 서명 확인: 미리보기 + 제출/다시서명 선택 - 거절 기능 (사유 입력) **서명 완료** (`sign/done.blade.php`): - 서명 완료/거절/기타 상태 분기 표시 - 계약 정보, 서명자 이름, 서명 일시 표시 --- ## 8. 핵심 플로우 ### 8.1 계약 생성 → 발송 ``` [관리자] 1. /esign/create 접속 2. 계약 정보 + PDF 업로드 + 서명자 정보 입력 3. POST /api/v1/esign/contracts → EsignContractService::create() → PDF 저장, SHA-256 해시, 계약 코드 생성 → 서명자 2인 생성 (access_token 128자) → 상태: DRAFT 4. /esign/{id}/fields 에서 서명 위치 지정 → POST /api/v1/esign/contracts/{id}/fields → configureFields() 호출 5. /esign/{id}/send 에서 발송 확인 → POST /api/v1/esign/contracts/{id}/send → 상태: DRAFT → PENDING → 첫 서명자: WAITING → NOTIFIED → EsignRequestMail 이메일 발송 ``` ### 8.2 서명 수행 ``` [서명자 B - 이메일 링크 클릭] 1. /esign/sign/{token} 접속 → auth 화면 2. GET /api/v1/esign/sign/{token} → 계약 정보 확인 3. POST /api/v1/esign/sign/{token}/otp/send → OTP 발송 4. POST /api/v1/esign/sign/{token}/otp/verify → OTP 검증 → sign_session_token 발급 → sessionStorage에 저장 5. /esign/sign/{token}/sign 이동 → sign 화면 6. 문서 확인 + 동의 체크 + 서명 입력 (SignaturePad) 7. POST /api/v1/esign/sign/{token}/submit → 서명 이미지 저장 (base64 → PNG 파일) → 상태: AUTHENTICATED → SIGNED → checkAndComplete() 호출 8. /esign/sign/{token}/done 이동 → done 화면 ``` ### 8.3 자동 완료 처리 ``` EsignSignService::checkAndComplete() 1. 모든 서명자 상태 확인 2. 전원 SIGNED → 계약 상태 COMPLETED + completed_at 설정 3. 아직 미서명자 있음 → PARTIALLY_SIGNED → 다음 서명 순서 서명자에게 이메일 발송 → 다음 서명자 상태: NOTIFIED ``` ### 8.4 상태 전이 **계약 상태**: ``` DRAFT → PENDING (send) PENDING → PARTIALLY_SIGNED (첫 서명 완료) PARTIALLY_SIGNED → COMPLETED (모든 서명 완료) DRAFT/PENDING → CANCELLED (관리자 취소) PENDING/PARTIALLY_SIGNED → REJECTED (서명자 거절) PENDING/PARTIALLY_SIGNED → EXPIRED (기한 초과, 추후 스케줄러) ``` **서명자 상태**: ``` WAITING → NOTIFIED (이메일 발송) NOTIFIED → AUTHENTICATED (OTP 인증) AUTHENTICATED → SIGNED (서명 완료) NOTIFIED/AUTHENTICATED → REJECTED (거절) ``` --- ## 9. 보안 설계 ### 9.1 토큰 기반 접근 | 토큰 | 용도 | 생성 | 유효기간 | |------|------|------|---------| | access_token | 서명 링크 URL | `Str::random(128)` | 계약 만료일까지 | | sign_session_token | OTP 인증 후 세션 | `Str::random(64)` | 별도 관리 없음 | ### 9.2 OTP 인증 - 6자리 숫자 (`random_int(100000, 999999)`) - 5분 유효 (`otp_expires_at = now + 5min`) - 최대 5회 시도 (`otp_attempts`) - 초과 시 에러 반환 (토큰 재사용 불가 상태) ### 9.3 파일 무결성 - 업로드 시 SHA-256 해시 계산 → `original_file_hash`에 저장 - 검증 시 `hash_equals()` 사용 (타이밍 공격 방지) - 서명 완료 PDF도 별도 해시 저장 (`signed_file_hash`) ### 9.4 멀티테넌트 - 모든 모델에 `BelongsToTenant` 트레이트 적용 - 공개 서명 API에서는 `withoutGlobalScopes()` 사용 - 감사 로그 기록 시 `logPublic()` 메서드로 tenant_id 명시 ### 9.5 감사 추적 - 모든 주요 행위를 `esign_audit_logs`에 기록 - IP 주소, User-Agent 자동 수집 - 감사 로그는 삭제 불가 (`SoftDeletes` 미적용) --- ## 10. i18n (국제화) ### message 키 (`lang/ko/message.php` → `esign` 배열) | 키 | 메시지 | |----|--------| | created | 전자계약이 생성되었습니다 | | cancelled | 전자계약이 취소되었습니다 | | sent | 서명 요청이 발송되었습니다 | | reminded | 리마인더가 발송되었습니다 | | fields_configured | 서명 필드가 설정되었습니다 | | otp_sent | 인증 코드가 발송되었습니다 | | otp_verified | 본인인증이 완료되었습니다 | | signed | 서명이 완료되었습니다 | | rejected | 서명이 거절되었습니다 | | completed | 전자계약이 완료되었습니다 | | verified | 문서 무결성이 확인되었습니다 | | downloaded | 문서가 다운로드되었습니다 | ### error 키 (`lang/ko/error.php` → `esign` 배열) | 키 | 메시지 | |----|--------| | invalid_token | 유효하지 않은 서명 링크입니다 | | token_expired | 서명 링크가 만료되었습니다 | | contract_not_signable | 현재 서명할 수 없는 계약입니다 | | already_completed | 이미 완료된 계약입니다 | | already_cancelled | 이미 취소된 계약입니다 | | already_signed | 이미 서명이 완료되었습니다 | | invalid_status_for_send | 발송할 수 없는 상태입니다 | | no_sign_fields | 서명 필드가 설정되지 않았습니다 | | cannot_remind | 리마인더를 발송할 수 없는 상태입니다 | | fields_only_in_draft | 초안 상태에서만 필드를 설정할 수 있습니다 | | not_verified | 본인인증이 필요합니다 | | otp_max_attempts | 인증 시도 횟수를 초과했습니다 | | otp_not_sent | 인증 코드가 발송되지 않았습니다 | | otp_expired | 인증 코드가 만료되었습니다 | | otp_invalid | 인증 코드가 올바르지 않습니다 | | file_not_found | 파일을 찾을 수 없습니다 | --- ## 11. 재사용 패턴 ### API 프로젝트 | 패턴 | 파일 | 용도 | |------|------|------| | Service 베이스 | `app/Services/Service.php` | tenantId(), apiUserId(), setContext() | | ApiResponse | `app/Helpers/ApiResponse.php` | handle() 패턴으로 일관된 응답 | | Auditable Trait | `app/Traits/Auditable.php` | created_by, updated_by, deleted_by 자동 관리 | | BelongsToTenant | `app/Traits/BelongsToTenant.php` | TenantScope 글로벌 스코프 | ### MNG 프로젝트 | 패턴 | 파일 | 용도 | |------|------|------| | 레이아웃 | `views/layouts/app.blade.php` | 사이드바 + 콘텐츠 영역 | | React 하이브리드 | `views/finance/journal-entries.blade.php` | CDN React + Babel 패턴 참고 | | HX-Redirect | 컨트롤러 패턴 | HTMX 부분 로드 시 전체 리로드 | | SidebarMenu | `app/Services/SidebarMenuService.php` | DB 기반 메뉴 | | DOCX→PDF | `app/Services/ESign/DocxToPdfConverter.php` | LibreOffice headless 변환 | | PDF 서명 합성 | `app/Services/ESign/PdfSignatureService.php` | FPDI/TCPDF 서명 오버레이 | --- ## 12. 추후 구현 예정 | 항목 | 우선순위 | 설명 | |------|---------|------| | ~~PDF 합성 (FPDI)~~ | ~~높음~~ | ~~원본 PDF에 서명 이미지 오버레이~~ → **구현 완료** (MNG PdfSignatureService) | | ~~DOCX→PDF 변환~~ | ~~높음~~ | ~~Word 문서 지원~~ → **구현 완료** (MNG DocxToPdfConverter + LibreOffice) | | 감사 증적 페이지 | 높음 | 완료 PDF 마지막에 감사 정보 페이지 추가 | | 파일 암호화 (AES-256) | 중간 | 원본 PDF 암호화 저장 | | 만료 자동 처리 | 중간 | 스케줄러로 expires_at 초과 계약 expired 처리 | | 리마인더 자동 발송 | 낮음 | 만료 3일 전 자동 리마인드 | | SMS OTP | 낮음 | 이메일 외 SMS 인증 지원 | | OTP bcrypt 해싱 | 중간 | 현재 평문 저장 → bcrypt 해싱 | | PDF 서명 텍스트 한글 | 낮음 | TCPDF CJK 폰트 추가 (현재 helvetica만 사용) | --- ## 13. 메뉴 추가 안내 메뉴 시더 실행 금지 규칙에 따라, 아래 tinker 명령어를 사용자가 직접 실행해야 합니다: ```bash # 1. 상위 메뉴 "전자계약 (E-Sign)" 추가 ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\" \\\$menu = App\\\\Models\\\\Commons\\\\Menu::create([ 'tenant_id' => 1, 'parent_id' => null, 'name' => '전자계약', 'url' => '/esign', 'icon' => 'ri-quill-pen-line', 'sort_order' => 25, 'is_active' => true, ]); echo 'Created menu ID: ' . \\\$menu->id; \"" # 2. 하위 메뉴 추가 (parent_id를 위에서 생성된 ID로 교체) ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\" App\\\\Models\\\\Commons\\\\Menu::create([ 'tenant_id' => 1, 'parent_id' => <상위메뉴ID>, 'name' => '계약 대시보드', 'url' => '/esign', 'icon' => 'ri-dashboard-line', 'sort_order' => 1, 'is_active' => true, ]); App\\\\Models\\\\Commons\\\\Menu::create([ 'tenant_id' => 1, 'parent_id' => <상위메뉴ID>, 'name' => '새 계약 생성', 'url' => '/esign/create', 'icon' => 'ri-add-line', 'sort_order' => 2, 'is_active' => true, ]); \"" ``` --- *이 문서는 SAM E-Sign v1.0 구현 결과를 기록한 것입니다. 추후 기능 추가 시 업데이트됩니다.*