5 июля 2011 г.

Восстановления пароля из Membership, в проекте на ASP.Net MVC

В большинстве проектов, для работы с пользователями, я использую встроенный Membership, инфраструктуру которого переношу в проект на самой первой стадии. Подробнее о том, как это сделать, можно посмотреть здесь.
Сам по себе Membership - штука очень хорошая. Новый, созданный MVC-проект включает AccountController, в котором имеется вся логика по работе с пользователями через Membership. Но вот, чего там нет, так это восстановления забытого пароля. В связи с этим, каждый выкручивается по-своему. Приходится писать дополнительный функционал. Этим мы сегодня и займемся.

Итак, постановка задачи.
У нас есть простой ASP.Net MVC проект, с подключенной базой данных, в которую интегрирован Membership. Необходимо реализовать возможность восстановить пароль пользователя.
Я считаю, что наилучший вариант восстановления - это сброс пароля. Поэтому, общая схема будет примерно такова:
  1. пользователь забыл пароль и нажал на соответствующую ссылку; 
  2. на почту, указанную при регистрации приходит письмо с ссылкой для сброса пароля;
  3. при переходе по ссылке из письма, пользователь попадает на страницу, где будет указан его новый пароль.
* Функционал изменения текущего пароля (а у нас это будут настоящие кракозябры) реализован в AccountController по умолчанию. Поэтому, реализовать нужно только интерфейс. Этого мы делать не будем.

Приступим к проекту.
Начнем с того, что добавим с наш *.dbml необходимые таблицы. В нашем случае это будут 2 служебные таблицы из инфраструктуры Membership, и одна наша, в которой содержатся дополнительные данные о пользователе:
Вся логика будет работать из Account-контроллера. Кроме того, создадим статический класс, в котором будет метод по отправке email-уведомления, и метод для шифрования ссылки (у меня такой класс называется Utils.cs, и размещен в \Models).

В общей сложности, нам понадобится 3 разных представления.
Для начала, создадим главный ActionResult, который будет отвечать за основные действия:

   1:  public ActionResult ResetPassword(string reset, string username, int? users)
   2:  {
   3:      if ((reset != null) && (username != null))
   4:      {
   5:          var currentUser = Membership.GetUser(username);
   6:   
   7:          if (currentUser != null)
   8:          {
   9:              if (Utils.HashResetParams(currentUser.UserName, 
currentUser.ProviderUserKey.ToString()) == reset)
  10:              {
  11:                  ViewData["newPass"] = currentUser.ResetPassword();
  12:                  ViewData["userName"] = username;
  13:                  return View("NewPassword");
  14:              }
  15:          }
  16:      }
  17:      else
  18:      {
  19:          if (users != null && users == 0)
  20:          {
  21:              ViewData["errorMsg"] = "Такой Email не зарегистрирован в системе!";
  22:          }
  23:      }
  24:   
  25:      return View();
  26:  }

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

<%= Html.ActionLink("password recovery", "ResetPassword")%>

Зачем нам тогда целых 3 параметра на входе в контроллер? Дело в том, что этот же ActionResult будет использован нами при переходе пользователем по ссылке из полученного письма. Но об этом по-порядку.
При первом попадании в этот метод все параметры будут пустыми, и в результате просто вызовется представление. Само представление содержит лишь поле для ввода пароля, и кнопку, ну и еще span, в котором будет выводиться ошибка:

   1:  <form id="recoveryForm" method="POST">
   2:  <p>
   3:      Please, enter your account Email:
   4:  </p>
   5:  <p>
   6:      <br />
   7:      <input type="text" id="email" name="email" />
   8:      <br />
   9:      <input class="buttonSubmit" type="submit" value="Next" />
  10:  </p>
  11:  </form>
  12:  <% if (ViewData["errorMsg"] != null)
  13:      {%>
  14:  <span><b>
  15:      <%= ViewData["errorMsg"].ToString()%></b></span>
  16:  <%} %>

Пойдем дальше по нашему сценарию...
Пользователь вводит в поле свой email, и нажимает кнопку, при чем происходит отправка формы, и вызов ActionResult:

   1:  [HttpPost]
   2:  public ActionResult ResetPassword(string email)
   3:  {
   4:      var person = m_DataManager.Persons.GetPersonByEmail(email);
   5:              
   6:      if (person != null)
   7:      {
   8:          var currentUser = Membership.GetUser(person.aspnet_User.UserName);
   9:          var hostUri = Request.Url.GetLeftPart(UriPartial.Authority);
  10:   
  11:          try
  12:          {
  13:              Utils.SendResetEmail(currentUser, (hostUri != string.Empty ? 
hostUri : "http://www.example.com"));
  14:              return RedirectToAction("PasswordResetSuccess");
  15:          }
  16:          catch (Exception)
  17:          {
  18:              return View();
  19:          }
  20:      }
  21:      else
  22:          return RedirectToAction("ResetPassword", new { users = 0 });
  23:  }

Вместе с постом, сюда приходит введенный емейл-адрес. Первым делом мы проверяем, имеется ли у нас зарегистрированный пользователь с таким адресом. За это отвечает простенький метод репозитория PersonRepository:

public class PersonRepository
{
    .........

    public Person GetPersonByEmail(string email)
    {
        return m_DataContext.Persons.Where(p => p.aspnet_Membership.LoweredEmail == 
email.ToLower()).SingleOrDefault();
    }
}

Если такого пользователя нет, то происходит редирект на ResetPassword, только уже с параметром users=0. При таком варианте ViewData заполнится текстом ошибки, которая будет показана юзеру на ResetPassword.aspx.
Если же пользователь с таким емейлом существует в системе, то мы вызываем метод отправки ему письма с дальнейшими инструкциями, и отправляем пользователя на страницу ожидания (PasswordResetSucccess.aspx), где будет простое сообщение о том, что на email отправлено письмо. Хочу заметить один момент, а именно строку:

var hostUri = Request.Url.GetLeftPart(UriPartial.Authority);

hostUri - корневой адрес сайта. Это необходимо для ссылки, отправляемой в письме. А так как я использую встроенный ASP.Net Development Server, то этот адрес у меня постоянно меняется. На внешнем хостинге, вместо этого можно использовать "http://www.example.com".

Теперь перейдем к методу оправки уведомления на почту. Это метод размещен в статическом классе Utils. Он принимает текущего юзера, и корневой адрес сайта, для формирования ссылки:

   1:  // Настройки для отправки писем
   2:  private const string SenderEmail = "*********@mail.ru";
   3:  private const string SenderPswd = "eh3A6kkc";
   4:   
   5:  //Send Email Method
   6:  public static void SendResetEmail(MembershipUser user, string hostUri)
   7:  {
   8:      var smtp = new SmtpClient("smtp.mail.ru", 25);
   9:      var message = new MailMessage(SenderEmail, user.Email.ToLower());
  10:      var sendCredential = new NetworkCredential(SenderEmail, SenderPswd);
  11:      smtp.Credentials = sendCredential;
  12:      message.SubjectEncoding = Encoding.UTF8;
  13:      message.Subject = "Восстановление пароля от лучшего в мире сервиса";
  14:      message.IsBodyHtml = true;
  15:      string link = hostUri + "/Account/ResetPassword/?username=" + user.UserName + "&reset=" + HashResetParams(user.UserName, user.ProviderUserKey.ToString());
  16:   
  17:      message.Body = "<p>Здравствуйте!<br/> Для сброса своего старого пароля пройдите по следующей ссылке: <a href='" + link + "'>" + link + "</a></p>";
  18:      message.Body += "<p>Если Вы не запрашивали сброс пароля, то просто проигнорируйте это письмо.</p>";
  19:      //smtp.EnableSsl = true;
  20:   
  21:      smtp.Send(message);
  22:  }

В константах содержатся данные для отправки письма через smtp. Сама процедура формирования и отправки письма довольно стандартная. Важно остановиться на одном моменте. При формировании тела письма, нам необходимо составить зашифрованную ссылку для сброса пароля. Это делается так:

string link = hostUri + "/Account/ResetPassword/?username=" + user.UserName + "&reset="
HashResetParams(user.UserName, user.ProviderUserKey.ToString());

Как видно из кода, мы формируем ссылку на метод ResetPassword, и передаем туда 2 параметра. Первый - имя пользователя, и второй - зашифрованный хеш имени пользователя и его Guid-айди поля. За хеширование отвечает метод HashResetParams, который также содержится в текущем классе:

   1:  //Method to hash parameters to generate the Reset URL
   2:  public static string HashResetParams(string username, string guid)
   3:  {
   4:      byte[] bytesofLink = Encoding.UTF8.GetBytes(username + guid);
   5:      System.Security.Cryptography.MD5 md5 = new System.Security.Cryptography.MD5CryptoServiceProvider();
   6:      string hashParams = BitConverter.ToString(md5.ComputeHash(bytesofLink));
   7:   
   8:      return hashParams;
   9:  }

Таким образом, пользователь получит ну ооочень непонятную ссылку, при переходе по которой, попадет все в тот же ActionResult ResetPassword:

   1:  public ActionResult ResetPassword(string reset, string username, int? users)
   2:  {
   3:      if ((reset != null) && (username != null))
   4:      {
   5:          var currentUser = Membership.GetUser(username);
   6:   
   7:          if (currentUser != null)
   8:          {
   9:              if (Utils.HashResetParams(currentUser.UserName, currentUser.ProviderUserKey.ToString()) == reset)
  10:              {
  11:                  ViewData["newPass"] = currentUser.ResetPassword();
  12:                  ViewData["userName"] = username;
  13:                  return View("NewPassword");
  14:              }
  15:          }
  16:      }
  17:      else
  18:      {
  19:          if (users != null && users == 0)
  20:          {
  21:              ViewData["errorMsg"] = "Такой Email не зарегистрирован в системе!";
  22:          }
  23:      }
  24:   
  25:      return View();
  26:  }

На этот раз у нас уже будут параметры, полученные из ссылки. В следствие чего, сначала мы проверяем, имеется ли пользователь с присланным логином. Если да, то опять вычисляем хеш с логин+айди пользователя, и сравниваем с тем, что пришел в запросе. Если они совпадают, то значит все чудесно, и по ссылке пришел нужный человек (именно тот, который запросил сброс).
Окончательным этапом этого метода является сброс пароля средствами Membership, и сохранение его (нового пароля) во ViewData, который показываем пользователю на следующей странице NewPassword, куда его и направляем.
Сама страница NewPassword просто отобразит пользователю его новый пароль. Это весь ее функционал.

Кажется все. На вид - довольно запутано, но если пройтись по всем пунктам, то становится ясно и просто.
Итак, проверим результат.


На странице входа в систему выбираем ссылку восстановления пароля:

После этого попадаем на страницу, где вводим email адрес, и запускаем процедуру сброса пароля:


После ввода корректного адреса, и нажатия на кнопку, мы попадаем на следующую страницу:

А на нашу почту (если она конечно наша), моментально приходит письмо от сервиса:

Вот она, страшная ссылка с хеш-кодом и именем пользователя.
Теперь, если я перейду по этой ссылке, и я действительно являюсь тем, за кого себя выдаю, то я попаду на последнюю страницу нашего процесса сброса пароля:

А вот и финиш! Пароль успешно сброшен. Остается только, при необходимости, изменить его в личном кабинете, если такой конечно имеется ))).

Вышло очень объемно, но, надеюсь полезно.
Я не спорю, возможно есть способы поудобнее, но меня устраивает именно этот. Пользователи тоже не жалуются.
_____
Исходники (*для использования не забудьте изменить почтовые данные в статическом классе)