- BrainTools - https://www.braintools.ru -

Делаем простой сервис для подписания документов по СМС

Компаниям часто приходится подписывать договоры и акты с клиентами. Полноценный ЭДО — это долго и дорого для простых задач, а сканы по почте и личные визиты — неудобны.

Закон № 63-ФЗ разрешает использовать простую электронную подпись (ПЭП). Это обычный код из СМС на телефон. Такой способ подтверждает согласие клиента и подходит для большинства гражданских договоров.

В статье расскажем, как собрать на Python сервис для подписания документов. Вы сможете встроить его в свои ИТ-процессы.

Архитектура решения

Мы соберём простой сервис, чтобы подтверждать согласие клиента и проверять подлинность подписанных документов.

Как работает подписание

  1. Пользователь загружает PDF-документ

  2. Сервис вычисляет и сохраняет хеш документа

  3. Клиент вводит номер телефона и получает СМС с одноразовым кодом

  4. После ввода кода система создаст штамп, встраивает его в 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'}
Делаем простой сервис для подписания документов по СМС - 1 [2]

Шаг 1. Модель данных

Сервис хранит не сами документы, а информацию о процессе. В базу мы записываем ключи доступа и контрольные суммы, а 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()
Делаем простой сервис для подписания документов по СМС - 2 [2]

Шаг 2. Загрузка документа и отправка СМС

На этом шаге привязываем файл к конкретному подписанту и отправляем СМС для проверки.

Пользователь загружает 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)
Делаем простой сервис для подписания документов по СМС - 3 [2]

Микрофронтенд

Делаем простой сервис для подписания документов по СМС - 4

Мы сделали простой интерфейс для работы с клиентом. Это одна серверная 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>
Делаем простой сервис для подписания документов по СМС - 5 [2]

Шаг 3. Создание штампа

Когда клиент подтвердит СМС, сервис создаст штамп и встроит его в PDF. Это не криптографическая подпись, а наглядное подтверждение сделки.

Мы создаём штамп как отдельный PDF-слой и накладываем его на документ. Так мы не меняем исходный файл и избегаем ошибок с вёрсткой и шрифтами оригинала. В штампе мы фиксируем:

  • номер телефона клиента

  • дату и время подписи

  • ID операции

Для работы используем библиотеку reportlab — она точно расставляет элементы по заданным координатам.

Стандартные шрифты reportlab не понимают русский язык. Если оставить всё как есть, вместо текста в штампе появятся пустые квадраты. Поэтому подключаем TTF-шрифт arial.ttf с поддержкой Unicode.

Делаем простой сервис для подписания документов по СМС - 6

Готовый штамп объединяем с оригиналом через библиотеку 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)
Делаем простой сервис для подписания документов по СМС - 7 [2]

Шаг 4. Сохранение результата и проверка подписи

Когда сервис создаст PDF, он запишет результат в базу и завершит операцию. Теперь файл можно проверить в любой момент.

Что сохраняет система

  • ссылку на подписанный PDF в хранилище

  • хеш итогового файла — signed_hash

  • время завершения сделки

После этого статус операции меняется на signed. Теперь исходный файл и данные о нём нельзя изменить.

Чтобы убедиться в подлинности документа, достаточно двух действий:

  1. Вычислите хеш файла

  2. Сравните его с 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>"
Делаем простой сервис для подписания документов по СМС - 8 [2]

Шаг 5. Проверка подлинности документа

Когда документ стал самостоятельным артефактом, который можно передавать другим людям, должен быть способ установить его истинность. Для этого мы создали валидатор — он подтверждает, что файл не меняли после подписи.

Валидатор вычисляет хеш загруженного документа и сравнивает его с 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 "⚠️ Документ загружен, но процесс подписания не завершен."
Делаем простой сервис для подписания документов по СМС - 9 [2]

Заключение

ПЭП через СМС помогает быстро подписывать документы с клиентами, когда скорость важнее формальностей. В основе решения лежит не сам 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

www.BrainTools.ru

Rambler's Top100