вторник, 1 ноября 2011 г.

Indy v.9, SMTP и SSL

Производственная необходимость, кто же, как не она, родимая, поставила задачу: надо сделать так, чтобы соединение с SMTP сервером производилось при помощи защищенного SSL канала.

Имеется написанный довольно давно почтовый клиент. Клиент написан на Delphi 6 с использованием набора компонент Indy версии 9.0.18. Таковы начальные условия. Необходимо, не переписывая кардинальным образом программу, расширить ее функционал требуемым образом, то есть, научить клиента общаться с SMTP серверами посредством защищенного SSL канала.

 Для начала, создаем в Delphi тестовый проект, не курочить же сразу работающую много лет софтину. Тем более, что ожидаются определенные трудности с реализацией, так сказать, чутье подсказывает...
Накидываем на форму всяческие контролы, которые позволят указать нам следующие параметры:

  • адрес SMTP сервера, с которым мы хотим связаться
  • порт SMTP сервера
  • требуется или нет авторизация на этом SMTP сервере
  • в случае, если авторизация нужна, то имя пользователя и его пароль
  • будет или нет использоваться SSL и какой версии
  • ну и всяческие атрибуты электронного письма, вроде адреса отправителя, адреса получателя, темы и, конечно же, само содержимое.

В качестве SMTP сервера для тестов, после совсем недолгих раздумий, был выбран... правильно, SMTP сервер, любезно предоставляемый великим и ужасным (кому как больше нравится) Google. Благо, настроить любой почтовый клиент, включая самописный, на отправку писем через него довольно просто. Адрес SMTP сервера smtp.gmail.com, для SSL соединения используется порт 465 (SSL, кстати, используется версии 3), для TLS соединения используется порт 587. Сервер требует авторизации, в качестве имени пользователя используется email существующей учетной записи GMail; с паролем тоже все понятно - он должен быть от этой же учетной записи. Вот, пожалуй, и все премудрости. Пока.

Итак, продолжаем накидывать компоненты на форму тестового приложения. На этот раз это будут:

  • TidSmtp, необходим для работы с SMTP сервером
  • TidMessage, нужен для создания отсылаемого сообщения
  • TIdSSLIOHandlerSocket, для создания SSL защищенного соединения
  • кнопка с символическим заголовком (Caption) "Послать", на обработчике OnClick которой и будут вершиться все необходимые для решения задачи действия.

Итак, пишем код того самого обработчика OnClick. Для начала переносим в компонент TidSMTP значения полей ввода для адреса SMTP сервера и его порта (не забываем привести номер порта к нужному типу). Если checkbox, определяющий, нужна или нет авторизация на SMTP сервере, взведен, то переносим в тот же компонент значения полей ввода имени и пароля пользователя.

Далее, анализируем содержимое combobox-а, определяющего, должно ли соединение быть защищенным, и, если да, то какой протокол и какая его версия будет использоваться. Тут я не стал изобретать велосипед. Известно, что Indy 9 может работать с SSL версии 2, SSL версии 3 и TLS версии 1. Есть у Indy еще хитрое значение версии 23, которое позволяет вопрос, какую версию SSL использовать, оставить на усмотрение Indy: в процессе выполнения станет ясно, какая версия нужна, она и будет использоваться. Соответствующий combobox я и заполнил символическими названиями поддерживаемых Indy протоколов и версий, на первой (нулевой, если уж быть совсем точным) строке поставив значение "Не использовать защищенное соединение". В коде же добавил нехитрый анализ индекса выбранного (на момент срабатывания OnClick кнопки) элемента combobox-а: если выбран любой элемент, кроме первого (ну, то есть, нулевого), в компоненте TIdSSLIOHandlerSocket проставляется соответствующее значение свойства SSLOptions.Method , а сам этот компонент прописывается в свойстве IOHandler компонента TidSMTP. Если же выбранным оказывалось значение "Не использовать...", то свойство IOHandler очищалось. Конечно, не забудем про компонент TidMessage, заполнив его свойства адресами отправителя и получателя, темой и самим содержанием отсылаемого сообщения. Вот приблизительно такой код у меня получился.
procedure TMainTestForm.btnSendClick(Sender: TObject);
begin
  // Подготовим сообщение
  msgContent.From.Address := edtFrom.Text;
  msgContent.Recipients.EMailAddresses := edtTo.Text;
  msgContent.Subject := edtSubject.Text;
  msgContent.Body.Text := mMessage.Lines.Text;

  // Подготовим соединение с SMTP сервером
  smtpClient.Host := edtHost.Text;
  smtpClient.Port := StrToInt(edtPort.Text);
  if chckAuthentication.Checked then
  begin
    smtpClient.AuthenticationType := atLogin;
    smtpClient.Username := edtUserName.Text;
    smtpClient.Password := edtPassword.Text;
  end
  else
    smtpClient.AuthenticationType := atNone;

  try
    // Проверим работоспособность OpenSSL
    if cbxSecurity.ItemIndex > 0 then
      TIdSSLContext.Create.Free;

    // Определим протокол и версию для защищенного соединения
    case cbxSecurity.ItemIndex of
      1: sslIOHandler.SSLOptions.Method := sslvSSLv2;
      2: sslIOHandler.SSLOptions.Method := sslvSSLv23;
      3: sslIOHandler.SSLOptions.Method := sslvSSLv3;
      4: sslIOHandler.SSLOptions.Method := sslvTLSv1;
    end;

    // Обработчик канала
    if cbxSecurity.ItemIndex <= 0 then
      smtpClient.IOHandler := nil
    else
      smtpClient.IOHandler := sslIOHandler;

    // Попытка соединения
    smtpClient.Connect();
    try
      // Отправим письмо
      smtpClient.Send(msgContent);
    finally
      smtpClient.Disconnect;
    end;

  except   // Что-то пошло не так
    on e: Exception do
    begin
      ShowMessage(e.Message);
      cbxSecurity.ItemIndex := 0;
    end;
  end;
end;

Запускаем программу, вбиваем значения для тестирования (не забываем, что используем SMTP сервер GMail) и смотрим, что получилось. Ничего хорошего.


Как говорится, сам дурак. Indy работает с SSL через динамические библиотеки OpenSSL и эти библиотеки надо Indy предоставить. Иначе имеем то, что имеем.

Исправляюсь, загружаю последнюю версию OpenSSL библиотек и подкладываю libeay32.dll и ssleay32.dll в каталог с программой. Итог - ровно такое же сообщение об ошибке. Где-то глубоко в памяти начинают всплывать смутные воспоминания, но рефлексы быстрее: отладка показывает, что не все функции, которые пытается получить из этих библиотек Indy, есть в последних, только что загруженных, версиях DLL. Ну да, конечно, память не зря выдавала смутные образы: старые версии Indy требуют для своей работы специально скомпилированные для Indy версии OpenSSL. Хорошо, что можно загрузить старые версии тоже. В результате метода проб и ошибок выясняю, что с моей установленной версией Indy без всевозможных дополнительных патчей работает OpenSSL версии 0.9.6m.

Хорошо, поехали дальше. программа запущена, данные введены, имеем:

  • если не используем защищенное соединение, то получаем ошибку, что в случае с используемым SMTP сервером ожидаемо
  • если используем  версию SSL 2, то получаем ошибку - еще бы, SMTP сервер GMail требует SSL 3
  • если используем SSL 3, о чудо! Работает!

Что-то как-то все относительно гладко. Ну и ладно. Еще один эксперимент и баиньки: выставляем использование TLS 1, меняем порт, запускаем... не работает.

А счастье было рядом. Но, не беда. После некоторого времени, проведенного за чтением различных источников, выясняется, что Indy 9 не поддерживает так называемого explicit TLS, то есть, явно указать, что  хочу использовать TLS, после чего просто использовать его, не получится. Однако, источники утверждают, что работать с TLS все-таки можно, надо лишь вручную использовать команду STARTTLS. Процесс должен выглядеть следующим образом: пытаешься соединиться с сервером, он тебе в ответ присылает указание, что можешь попробовать использовать STARTTLS. После этого посылаешь серверу эту команду и все должно наладиться. Что ж, попробуем. Немного изменяем код:
procedure TMainTestForm.btnSendClick(Sender: TObject);
begin
  // Подготовим сообщение
  msgContent.From.Address := edtFrom.Text;
  msgContent.Recipients.EMailAddresses := edtTo.Text;
  msgContent.Subject := edtSubject.Text;
  msgContent.Body.Text := mMessage.Lines.Text;

  // Подготовим соединение с SMTP сервером
  smtpClient.Host := edtHost.Text;
  smtpClient.Port := StrToInt(edtPort.Text);
  if chckAuthentication.Checked then
  begin
    smtpClient.AuthenticationType := atLogin;
    smtpClient.Username := edtUserName.Text;
    smtpClient.Password := edtPassword.Text;
  end
  else
    smtpClient.AuthenticationType := atNone;

  try
    // Проверим работоспособность OpenSSL
    if cbxSecurity.ItemIndex > 0 then
      TIdSSLContext.Create.Free;

    // Определим протокол и версию для защищенного соединения
    case cbxSecurity.ItemIndex of
      1: sslIOHandler.SSLOptions.Method := sslvSSLv2;
      2: sslIOHandler.SSLOptions.Method := sslvSSLv23;
      3: sslIOHandler.SSLOptions.Method := sslvSSLv3;
      4: sslIOHandler.SSLOptions.Method := sslvTLSv1;
    end;

    // Обработчик канала
    if cbxSecurity.ItemIndex <= 0 then
      smtpClient.IOHandler := nil
    else
      smtpClient.IOHandler := sslIOHandler;

    // Попытка соединения
    smtpClient.Connect();
    try
      // Если сервер требует STARTTLS
      if smtpClient.LastCmdResult.Text.IndexOf('STARTTLS') <> -1 then
        smtpClient.SendCmd('STARTTLS', 220);
      
      // Отправим письмо
      smtpClient.Send(msgContent);
    finally
      smtpClient.Disconnect;
    end;

  except   // Что-то пошло не так
    on e: Exception do
    begin
      ShowMessage(e.Message);
      cbxSecurity.ItemIndex := 0;
    end;
  end;
end;

Запускаем прогу. Не работает. Дебагируем и понимаем, что до анализа возврата от сервера дело просто не доходит. После вызова метода Connect у объекта TidSMTP получаем Exception. Курим документацию и всевозможные форумы в интернете. В конце концов, на одном из форумов нахожу упоминание о том, что после посылки команды STARTTLS свойство  PassThrough у объекта TIdSSLIOHandlerSocket надо установить в False. В переводе с эзопова языка это значит, что перед посылкой этой команды это свойство должно быть установлено в True. Дальнейшие изыскания прояснили суть свойства PassThrough: установка этого свойства позволяет игнорировать использование SSL. То есть, установив это свойство в True мы пытаемся соединиться с сервером по открытому каналу, и в ответ на это сервер нам и должен вернуть предложение попытаться использовать команду STARTTLS. Исправляем код.
procedure TMainTestForm.btnSendClick(Sender: TObject);
begin
  // Подготовим сообщение
  msgContent.From.Address := edtFrom.Text;
  msgContent.Recipients.EMailAddresses := edtTo.Text;
  msgContent.Subject := edtSubject.Text;
  msgContent.Body.Text := mMessage.Lines.Text;

  // Подготовим соединение с SMTP сервером
  smtpClient.Host := edtHost.Text;
  smtpClient.Port := StrToInt(edtPort.Text);
  if chckAuthentication.Checked then
  begin
    smtpClient.AuthenticationType := atLogin;
    smtpClient.Username := edtUserName.Text;
    smtpClient.Password := edtPassword.Text;
  end
  else
    smtpClient.AuthenticationType := atNone;

  try
    // Проверим работоспособность OpenSSL
    if cbxSecurity.ItemIndex > 0 then
      TIdSSLContext.Create.Free;

    // Определим протокол и версию для защищенного соединения
    case cbxSecurity.ItemIndex of
      1: sslIOHandler.SSLOptions.Method := sslvSSLv2;
      2: sslIOHandler.SSLOptions.Method := sslvSSLv23;
      3: sslIOHandler.SSLOptions.Method := sslvSSLv3;
      4: begin
         sslIOHandler.SSLOptions.Method := sslvTLSv1;
         // Из-за невозможности немедленного использования TLS запретим
         // шифрование
         sslIOHandler.PassThrough := True;
         end;
    end;

    // Обработчик канала
    if cbxSecurity.ItemIndex <= 0 then
      smtpClient.IOHandler := nil
    else
      smtpClient.IOHandler := sslIOHandler;

    // Попытка соединения
    smtpClient.Connect();
    try
      // Если сервер требует STARTTLS
      if smtpClient.LastCmdResult.Text.IndexOf('STARTTLS') <> -1 then
      begin
        smtpClient.SendCmd('STARTTLS', 220);
        // Разрешим использование шифрования
        TIdSSLIOHandlerSocket(smtpClient.IOHandler).PassThrough := False;
      end;

      // Отправим письмо
      smtpClient.Send(msgContent);
    finally
      smtpClient.Disconnect;
    end;

  except   // Что-то пошло не так
    on e: Exception do
    begin
      ShowMessage(e.Message);
      cbxSecurity.ItemIndex := 0;
    end;
  end;
end;

Запускаем. Опять ошибка, но на этот раз проблема с аутентификацией. Оказалось, что после установления TLS соединения (прохождения процедуры TLS handshake) компонент TidSMPT должен узнать, что изменилось на стороне сервера, например, в реализации AUTH. Для этого необходимо использовать команду EHLO и, после ее успешного завершения, обновить свойство AuthSchemesSupported объекта TIdSMTP. Для обновления этого свойства у TidSMTP есть метод GetAuthTypes, но он, по закону бутерброда, объявлен в секции protected. Поэтому для доступа к нему придется применить давно известный метод доступа к protected элементам классов, а именно, объявить прямого наследника от интересующего класса и использовать преобразование типов: преобразовать TidSMTP к его наследнику для доступа к protected методу (известно, что Delphi разрешает доступ к protected элементам класса из других классов, описанных в том же исходном файле). После такого изменения кода все работает.
type
  TIdSMTPAccess = class(TIdSMTP)
end;

procedure TMainTestForm.btnSendClick(Sender: TObject);
var NameToSend: String;
begin
  // Подготовим сообщение
  msgContent.From.Address := edtFrom.Text;
  msgContent.Recipients.EMailAddresses := edtTo.Text;
  msgContent.Subject := edtSubject.Text;
  msgContent.Body.Text := mMessage.Lines.Text;

  // Подготовим соединение с SMTP сервером
  smtpClient.Host := edtHost.Text;
  smtpClient.Port := StrToInt(edtPort.Text);
  if chckAuthentication.Checked then
  begin
    smtpClient.AuthenticationType := atLogin;
    smtpClient.Username := edtUserName.Text;
    smtpClient.Password := edtPassword.Text;
  end
  else
    smtpClient.AuthenticationType := atNone;

  try
    // Проверим работоспособность OpenSSL
    if cbxSecurity.ItemIndex > 0 then
      TIdSSLContext.Create.Free;

    // Определим протокол и версию для защищенного соединения
    case cbxSecurity.ItemIndex of
      1: sslIOHandler.SSLOptions.Method := sslvSSLv2;
      2: sslIOHandler.SSLOptions.Method := sslvSSLv23;
      3: sslIOHandler.SSLOptions.Method := sslvSSLv3;
      4: begin
         sslIOHandler.SSLOptions.Method := sslvTLSv1;
         // Из-за невозможности немедленного использования TLS запретим
         // шифрование
         sslIOHandler.PassThrough := True;
         end;
    end;

    // Обработчик канала
    if cbxSecurity.ItemIndex <= 0 then
      smtpClient.IOHandler := nil
    else
      smtpClient.IOHandler := sslIOHandler;

    // Попытка соединения
    smtpClient.Connect();
    try
      // Если сервер требует STARTTLS
      if smtpClient.LastCmdResult.Text.IndexOf('STARTTLS') <> -1 then
      begin
        smtpClient.SendCmd('STARTTLS', 220);
        // Разрешим использование шифрования
        TIdSSLIOHandlerSocket(smtpClient.IOHandler).PassThrough := False;
        // Перечитаем возможности сервера в плане AUTH
        smtpClient.AuthSchemesSupported.Clear;
        if Length(smtpClient.HeloName) &gt; 0 then
          NameToSend := smtpClient.HeloName
        else
          NameToSend := smtpClient.LocalName;
        if smtpClient.SendCmd('EHLO ' + NameToSend) = 250 then
          TIdSMTPAccess(smtpClient).GetAuthTypes;
      end;

      // Отправим письмо
      smtpClient.Send(msgContent);
    finally
      smtpClient.Disconnect;
    end;

  except   // Что-то пошло не так
    on e: Exception do
    begin
      ShowMessage(e.Message);
      cbxSecurity.ItemIndex := 0;
    end;
  end;
end;

В качестве послесловия хочу сказать, что была еще одна очень интересная проблема, связанная с использованием прокси-сервера для доступа к SMTP по защищенному каналу. Иногда, очень редко ;) , доступ к определенным портам или адресам, оказывается заблокирован. Причин, почему это делается, может быть множество, речь сейчас не об этом. Речь о том, что иногда эти ограничения можно обойти, если есть доступ к какому-нибудь хитрому прокси. У меня была возможность попасть в описываемую ситуацию и попробовать выбраться из нее. Опять с использованием Indy 9-ой версии. Но об этом - в одном из следующих постов.

И еще... загрузить исходные файлы тестового проекта можно по следующей ссылке:
Исходные тексты
а исполняемый файл вместе с библиотеками OpenSSL по ссылке:
Исполняемые файлы и библиотеки

Комментариев нет:

Отправить комментарий