- BrainTools - https://www.braintools.ru -
Компаниям часто приходится подписывать договоры и акты с клиентами. Полноценный ЭДО — это долго и дорого для простых задач, а сканы по почте и личные визиты — неудобны.
Закон № 63-ФЗ разрешает использовать простую электронную подпись (ПЭП). Это обычный код из СМС на телефон. Такой способ подтверждает согласие клиента и подходит для большинства гражданских договоров.
В статье расскажем, как собрать на Python сервис для подписания документов. Вы сможете встроить его в свои ИТ-процессы.
Мы соберём простой сервис, чтобы подтверждать согласие клиента и проверять подлинность подписанных документов.
Пользователь загружает PDF-документ
Сервис вычисляет и сохраняет хеш документа
Клиент вводит номер телефона и получает СМС с одноразовым кодом
После ввода кода система создаст штамп, встраивает его в PDF и добавляет хеш файла со штампом
Документ всегда сопровождается проверяемым цифровым слепком.
Бэкенд на Python и Flask принимает файлы, проверяет коды и управляет статусами подписания
Хранилище SQLite с номерами телефонов, хешами и временем подписания. С��ми PDF лежат отдельно — в облаке или на диске
СМС-подтверждение через SMS API [1] МТС Exolve
PDF-движок рисует штамп и встраивает его в файл, не меняя основной текст страниц
Мы сохраняем хеш до и после подписи. Это поможет вам убедиться, что документ настоящий и в него не вносили правки после того, как клиент ввёл код.
Сервис подтверждает, что клиент владеет номером телефона и согласен с условиями. Он обеспечивает проверяемость файла, но не заменяет полноценный ЭДО и не устанавливает личность по паспорту. Это простое решение для быстрой автоматизации.
Шаг 0. Подготовка
Соберём настройки в одном конфиге, вынесем туда пути к файлам, секреты и доступы к СМС. Так вы быстрее запустите проект и перенесёте его в продакшен без правок кода.
Также ограничим размер файла до 16 МБ — этого хватит для обычных PDF и защитит систему от слишком тяжёлых загрузок.
import os
class Config:
# В реальном проекте ключи берем из os.environ
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
EXOLVE_API_KEY = "YOUR_API_KEY" # Ваш ключ от МТС Exolve
EXOLVE_SENDER = "ExolveSMS" # Имя отправителя
# Настройки базы и папок
SQLALCHEMY_DATABASE_URI = 'sqlite:///edo.db'
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads')
# Security: Ограничиваем размер файла (16 MB) и расширения
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
ALLOWED_EXTENSIONS = {'pdf'}
Сервис хранит не сами документы, а информацию о процессе. В базу мы записываем ключи доступа и контрольные суммы, а PDF-файлы отправляем в отдельное хранилище. В нашем примере это локальная папка, но для реальных задач лучше выбрать S3-совместимое решение.
Все этапы работы мы фиксируем в таблице sign_requests. В ней видно всё: от первой загрузки файла до готового документа со штампом.
Поля таблицы sign_requests
id — уникальный номер операции
status — состояние процесса: отправлено СМС, подтверждено или подписано
original_key — ссылка на исходный PDF в хранилище
original_hash — хеш оригинала. Он подтверждает, что клиент подписал именно ту версию файла, которую видел на экране
phone — номер телефона для проверки
sms_request_id или sms_code_hash — ID сообщения из SMS API
confirmed_at — время, когда клиент подтвердил код
signed_key — ссылка на готовый файл со штампом
signed_hash — хеш итогового документа для контроля целостности
created_at и updated_at — временные метки
models.py [3]
import hashlib
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class DocumentTransaction(db.Model):
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255))
phone_number = db.Column(db.String(20))
# Храним оба состояния документа
original_hash = db.Column(db.String(64), index=True)
signed_hash = db.Column(db.String(64), index=True, nullable=True)
status = db.Column(db.String(20), default='PENDING') # PENDING -> SIGNED
created_at = db.Column(db.DateTime, default=datetime.utcnow)
signed_at = db.Column(db.DateTime, nullable=True)
def calculate_file_hash(file_stream):
"""Считает SHA-256 хеш файла. Важно: читаем чанками, чтобы не забить память."""
sha256 = hashlib.sha256()
file_stream.seek(0)
while True:
chunk = file_stream.read(65536)
if not chunk:
break
sha256.update(chunk)
file_stream.seek(0) # Возвращаем каретку в начало для дальнейшей работы
return sha256.hexdigest()
На этом шаге привязываем файл к конкретному подписанту и отправляем СМС для проверки.
Пользователь загружает PDF-файл и указываете номер телефона. Система сохранит документ в хранилище, вычислит его хеш и создаст запись в таблице sign_requests со статусом pending. Мы сразу привязываем номер к операции — он станет фактором подтверждения для простой электронной подписи.
Далее сервис отправляет СМС с одноразовым кодом через SMS API [1]. Метод вернёт идентификатор sms_request_id: система запишет его в базу и переведёт операцию в статус sms_sent. На этом этапе мы не храним код в открытом виде. Сервис только подтверждает запрос к API и ждёт действий от пользователя.
С системе сохранится:
Исходный PDF и его хеш
Номер телефона подписанта
Идентификатор отправленного СМС
Статус sms_sent
Теперь можно переходить к проверке кода и фиксации сделки.
app.py
import logging
import os
import random
import requests
from datetime import datetime
from flask import Flask, request, session, render_template, send_file
from werkzeug.utils import secure_filename
# Импортируем наши модули (которые мы описали в других шагах)
from config import Config
from models import db, DocumentTransaction, calculate_file_hash
from pdf_utils import add_sign_stamp
app = Flask(__name__)
app.config.from_object(Config)
# Инициализируем БД
db.init_app(app)
# Настраиваем профессиональное логирование
# Audit Log важен для разбора спорных ситуаций в будущем
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] AUDIT: %(message)s',
handlers=[
logging.FileHandler("edo_service.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("EDO_Service")
# Создаем таблицы БД при первом запуске (для простоты демо)
with app.app_context():
if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER'])
db.create_all()
def allowed_file(filename):
"""Проверка расширения файла (Security Check)"""
return '.' in filename and
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
def send_sms_code(phone, code):
"""Отправка SMS через МТС Exolve"""
url = "https://api.exolve.ru/messaging/v1/SendSMS"
headers = {"Authorization": f"Bearer {app.config['EXOLVE_API_KEY']}"}
payload = {
"number": app.config['EXOLVE_SENDER'],
"destination": phone,
"text": f"Код подписи: {code}. Никому не сообщайте."
}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=5)
if resp.status_code == 200:
logger.info(f"SMS отправлено на {phone}")
return True
logger.error(f"Ошибка SMS провайдера: {resp.text}")
return False
except Exception as e:
logger.critical(f"Сбой сети при отправке SMS: {e}")
return False
# --- Роуты приложения ---
@app.route('/', methods=['GET'])
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
# 1. Валидация входных данных
if 'file' not in request.files or 'phone' not in request.form:
return "Некорректный запрос", 400
file = request.files['file']
phone = request.form['phone']
if file.filename == '' or not allowed_file(file.filename):
logger.warning(f"Попытка загрузки недопустимого файла: {file.filename}")
return "Разрешены только PDF файлы", 400
# 2. Сохраняем оригинал
# Считаем хеш прямо из потока, не сохраняя пока файл
orig_hash = calculate_file_hash(file.stream)
safe_name = secure_filename(f"{orig_hash}.pdf")
file_path = os.path.join(app.config['UPLOAD_FOLDER'], safe_name)
file.save(file_path)
logger.info(f"Загружен документ. Хеш: {orig_hash}, Инициатор: {phone}")
# 3. Генерируем код и отправляем
code = str(random.randint(1000, 9999))
if send_sms_code(phone, code):
# Сохраняем контекст операции в сессии
session['signing_context'] = {
'phone': phone,
'orig_hash': orig_hash,
'file_path': file_path,
'code': code,
'original_filename': file.filename
}
return render_template('verify.html', phone=phone)
return "Ошибка отправки SMS", 500
if __name__ == '__main__':
app.run(debug=True, port=5000)
Микрофронтенд

Мы сделали простой интерфейс для работы с клиентом. Это одна серверная HTML-страница без лишней логики: она принимает номер телефона и код из СМС, а затем передаёт данные на сервер.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>ПЭП Подпись</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; height: 100vh; align-items: center; background: #f0f2f5; }
.card { background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); text-align: center; width: 300px; }
input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; }
button { width: 100%; padding: 10px; background: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; }
button:hover { background: #0056b3; }
</style>
</head>
<body>
<div class="card">
<h2>📑 Подписать PDF</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="text" name="phone" placeholder="+79990000000" required>
<input type="file" name="file" accept="application/pdf" required>
<button type="submit">Получить код</button>
</form>
</div>
</body>
</html>
Когда клиент подтвердит СМС, сервис создаст штамп и встроит его в PDF. Это не криптографическая подпись, а наглядное подтверждение сделки.
Мы создаём штамп как отдельный PDF-слой и накладываем его на документ. Так мы не меняем исходный файл и избегаем ошибок с вёрсткой и шрифтами оригинала. В штампе мы фиксируем:
номер телефона клиента
дату и время подписи
ID операции
Для работы используем библиотеку reportlab — она точно расставляет элементы по заданным координатам.
Стандартные шрифты reportlab не понимают русский язык. Если оставить всё как есть, вместо текста в штампе появятся пустые квадраты. Поэтому подключаем TTF-шрифт arial.ttf с поддержкой Unicode.

Готовый штамп объединяем с оригиналом через библиотеку pypdf. В итоге:
Страницы оригинала остаются прежними
Штамп ложится отдельным слоем
Результат всегда предсказуем
Затем сервис вычислит хеш готового файла и сохранит его вместе с данными о документе.
pdf_utils.py
import io
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont # Исправлено: TTFont
from pypdf import PdfReader, PdfWriter
# Регистрируем шрифт с поддержкой кириллицы
try:
# Скачайте файл arial.ttf (например, из Windows/Fonts) и положите в папку с проектом
pdfmetrics.registerFont(TTFont('Arial', 'arial.ttf'))
FONT_NAME = 'Arial'
except:
print("⚠️ Шрифт arial.ttf не найден. Кириллица не будет отображаться!")
FONT_NAME = 'Helvetica'
def add_sign_stamp(input_path, output_path, phone, date_str):
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
# Рисуем синюю рамку и текст
can.setStrokeColorRGB(0, 0, 1) # Синий цвет
can.rect(50, 50, 500, 60)
can.setFillColorRGB(0, 0, 1) # Текст тоже синий
can.setFont(FONT_NAME, 12)
can.drawString(60, 90, f"ДОКУМЕНТ ПОДПИСАН ПРОСТОЙ ЭЛЕКТРОННОЙ ПОДПИСЬЮ")
can.setFont(FONT_NAME, 10)
can.drawString(60, 75, f"Телефон: {phone}")
can.drawString(60, 60, f"Дата: {date_str} | ID: SMS-CONFIRMED")
can.save()
packet.seek(0)
# Склеиваем слои
new_pdf = PdfReader(packet)
existing_pdf = PdfReader(open(input_path, "rb"))
output = PdfWriter()
for i in range(len(existing_pdf.pages)):
page = existing_pdf.pages[i]
if i == len(existing_pdf.pages) - 1: # Штамп только на последней странице
page.merge_page(new_pdf.pages[0])
output.add_page(page)
with open(output_path, "wb") as f:
output.write(f)
Когда сервис создаст PDF, он запишет результат в базу и завершит операцию. Теперь файл можно проверить в любой момент.
Что сохраняет система
ссылку на подписанный PDF в хранилище
хеш итогового файла — signed_hash
время завершения сделки
После этого статус операции меняется на signed. Теперь исходный файл и данные о нём нельзя изменить.
Чтобы убедиться в подлинности документа, достаточно двух действий:
Вычислите хеш файла
Сравните его с signed_hash в базе
Если значения совпадают, значит, документ не меняли после подписи. При необходимости можно также проверить хеш оригинала через original_hash.
app.py [4]:
from datetime import datetime
from models import DocumentTransaction, db
from pdf_utils import add_sign_stamp
@app.route('/verify', methods=['POST'])
def verify_code():
user_code = request.form['code']
ctx = session.get('signing_context')
# Проверка кода (в продакшене добавьте лимит попыток!)
if not ctx or ctx['code'] != user_code:
return "Неверный код", 400
signed_filename = f"signed_{ctx['orig_hash']}.pdf"
signed_path = f"uploads/{signed_filename}"
# Ставим штамп
add_sign_stamp(ctx['file_path'], signed_path, ctx['phone'], datetime.utcnow().isoformat())
# Считаем хеш ПОДПИСАННОГО файла
with open(signed_path, 'rb') as f:
signed_hash = calculate_file_hash(f)
# Пишем транзакцию
doc = DocumentTransaction(
phone_number=ctx['phone'],
original_hash=ctx['orig_hash'],
signed_hash=signed_hash, # Сохраняем "слепок" результата
status='SIGNED',
signed_at=datetime.utcnow()
)
db.session.add(doc)
db.session.commit()
return f"Успешно! <a href='/download/{signed_filename}'>Скачать подписанный файл</a>"
Когда документ стал самостоятельным артефактом, который можно передавать другим людям, должен быть способ установить его истинность. Для этого мы создали валидатор — он подтверждает, что файл не меняли после подписи.
Валидатор вычисляет хеш загруженного документа и сравнивает его с signed_hash, который мы сохранили в базе. Если значения совпадают, значит, в файл не вносили правки. Благодаря двойному хешированию, мы узнаем документ, даже если пользователь загрузит чистый оригинал, который был у него до подписания.
@app.route('/check_validity', methods=['POST'])
def check_validity():
file = request.files['file']
file_hash = calculate_file_hash(file.stream)
# Ищем хеш в ОБЕИХ колонках БД (SQLAlchemy OR)
doc = DocumentTransaction.query.filter(
(DocumentTransaction.original_hash == file_hash) |
(DocumentTransaction.signed_hash == file_hash)
).first()
if not doc:
return "❌ Документ не найден в реестре."
if doc.status == 'SIGNED':
return f"✅ Корректно. Подписан: {doc.signed_at} владельцем {doc.phone_number}"
else:
return "⚠️ Документ загружен, но процесс подписания не завершен."
ПЭП через СМС помогает быстро подписывать документы с клиентами, когда скорость важнее формальностей. В основе решения лежит не сам PDF-файл, а фиксация процесса: контрольные суммы, время и владение номером телефона.
Помните об ограничениях модели. СМС подтверждает владение номером, но не личность человека. Если нужна строгая проверка, добавьте другие факторы — например, перевод с именной карты. Так вы свяжете ФИО и телефон в одной операции.
В нашем примере за целостность данных отвечает владелец базы. Чтобы исключить подмену записей, публикуйте хеши во внешнем хранилище или блокчейне. Это усложнит систему, зато подделать подпись задним числом станет математически [5] невозможно.
Развивайте архитектуру под свои задачи. Вы сможете извлекать номер из реквизитов, генерировать PDF на лету или отправлять ссылку на подписание в мессенджеры. Сервис сам найдёт контакт и отправит СМС.
Этот подход даёт прозрачный результат, если вам нужно автоматизировать конкретный процесс, а не внедрять громоздкий ЭДО. Усиливайте систему, когда требования вырастут.
Код проекта на гитхабе [6].
Автор: Katner
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/26941
URLs in this post:
[1] SMS API: https://docs.exolve.ru/docs/ru/api-reference/sms-api/
[2] Image: https://sourcecraft.dev/
[3] models.py: http://models.py
[4] app.py: http://app.py
[5] математически: http://www.braintools.ru/article/7620
[6] на гитхабе: https://github.com/duckdevdotdev/postprod-article-jan2026-mini-docs-sign-solution
[7] Источник: https://habr.com/ru/companies/exolve/articles/1008924/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1008924
Нажмите здесь для печати.