Adding Email Confirmation to ASP.NET MVC

UPDATE October 8th, 2011: I've written a new post that uses ASP.NET MVC3 and Nuget. UPDATE 2 October 9th, 2011: I've re-written this post to incorporate the improvements suggested by David in the comments. Wow - they really simplified the code! Sample Application Code

Problem

On one of the sites I run, I ask people to register in order to gain access to current data. The site is based on a book by Martin Zweig and calculates his Zweig Model – or at least it will. Right now, it only calculates one of the constituent components – the Prime Rate Indicator. The book was published in 1993 and I only permit unregistered users to have access to the data through the publication of the book. If the user wants to see current data, she has to register using the stock ASP.NET MVC registration process. Out of the box, there isn’t any email confirmation procedure, so I end up with a fair amount of test@test.com addresses. Now, the only reason that I ask people to register is to gauge interest in the site. I currently have zero plans to do anything with the registration – but may elect do more if and when I ever add more to the site. This is how world domination begins. Or something. So – how to cut down on the faux registrations, while not adding too many hoops for the user to jump through? A common pattern is to send an email to the address provided by the user, with a link that, when clicked, activates their registration. Sounds easy.

Solution

In the out-of-the-box code template for ASP.NET MVC, the Register action verifies the MembershipCreateStatus, and if all is kosher, it signs the user in (line 11 below).

[HttpPost]
public ActionResult Register(RegisterModel model)
{
    if (ModelState.IsValid)
    {
        // Attempt to register the user
        MembershipCreateStatus createStatus = MembershipService.CreateUser(model.UserName,
               model.Password, model.Email);
        if (createStatus == MembershipCreateStatus.Success)
        {
            FormsService.SignIn(model.UserName, false /* createPersistentCookie */);
            return RedirectToAction("Index", "Home");
        }
        else
        {
            ModelState.AddModelError("", AccountValidation.ErrorCodeToString(createStatus));
        }
    }

    // If we got this far, something failed, redisplay form
    ViewData["PasswordLength"] = MembershipService.MinPasswordLength;
    return View(model);
}

To override this behavior, we can make use of the IsApproved property on the MembershipUser class. We want to set the property to “false” until the user has clicked the confirmation link. To do that, we need to dig into the implementation of the MembershipUser.CreateUser method, which is buried in AccountMembershipService class of the AccountModels.cs file.

  public MembershipCreateStatus CreateUser(string userName, string password, string email)
  {
      if (String.IsNullOrEmpty(userName)) throw
                  new ArgumentException("Value cannot be null or empty.", "userName");
      if (String.IsNullOrEmpty(password)) throw
                  new ArgumentException("Value cannot be null or empty.", "password");
      if (String.IsNullOrEmpty(email)) throw
                  new ArgumentException("Value cannot be null or empty.", "email");

      MembershipCreateStatus status;
          // ORIGINAL: 6th Parameter is IsApproved property - which defaults to true
          //_provider.CreateUser(userName, password, email, null, null, true, null, out status);
          // MODIFICATION: Set the IsApproved property to false
          _provider.CreateUser(userName, password, email, null, null, false, null, out status);
      return status;
  }

With that done, the user will not be approved when they are created - so we'll send the user an email with a hyperlink in it that, when clicked, will set their status to Approved.

  [HttpPost]
  public ActionResult Register(RegisterModel model)
  {
      if (ModelState.IsValid)
      {
          // Attempt to register the user
          MembershipCreateStatus createStatus = MembershipService.CreateUser(model.UserName,
                     model.Password, model.Email);

          if (createStatus == MembershipCreateStatus.Success)
          {
              /* TODO: At this point, the user has created a valid account (but is unapproved)
               * We need to send the user a confirmation email and then
               * redirect them to a confirmation page
               * that says, 'thank you for registering, please check your
               * email form a confirmation link. */

              MembershipService.SendConfirmationEmail(model.UserName);

              //FormsService.SignIn(model.UserName, false /* createPersistentCookie */);
              return RedirectToAction("confirmation");
          }
          else
          {
              ModelState.AddModelError("", AccountValidation.ErrorCodeToString(createStatus));
          }
      }

      // If we got this far, something failed, redisplay form
      ViewData["PasswordLength"] = MembershipService.MinPasswordLength;
      return View(model);
  }

Now, this won't compile because the MembershipService interface does not include a SendConfirmationEmail method. So, mosey on over to Models\AccountModels.cs and look for IMembershipService definition. We're going to add the method to the interface's definition, like this:

public interface IMembershipService
{
    int MinPasswordLength { get; }

    bool ValidateUser(string userName, string password);
    MembershipCreateStatus CreateUser(string userName, string password, string email);
    bool ChangePassword(string userName, string oldPassword, string newPassword);

    // Additions to Interface for EmailConfirmation...
    void SendConfirmationEmail(string userName);
}

Now let's implement the method in the AccountMembershipService class:

public void SendConfirmationEmail(string userName)
{
    MembershipUser user = Membership.GetUser(userName);
    string confirmationGuid = user.ProviderUserKey.ToString();
    string verifyUrl = HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority) +
                     "/account/verify?ID=" + confirmationGuid;

    var message = new MailMessage("YOUR_USER_ACCOUNT_HERE@YOUR_DOMAIN_HERE", user.Email)
    {
        Subject = "Please confirm your email",
        Body = verifyUrl

    };

    var client = new SmtpClient();

    client.Send(message);
}

Here, we're setting the confirmationGuid to the user's ProviderUserKey property. This is the GUID stored in the database that uniquely identifies the user. We then set the verifyUrl to the Verify action on the Account controller - passing the confirmationGuid as the ID parameter. We then new-up a simple email message that just contains a hyperlink consisting of the verifyUrl. In order for this email to work, you'll need to import the

using System.Net.Mail;
using System.Configuration;

namespaces, and you'll need to add the following to your web.config file:

<system.net>
  <mailSettings>
    <smtp deliveryMethod="Network">
                <network host="YOUR_MAIL_HOST" userName="YOUR_USER_NAME@YOUR_DOMAIN"
                                     password="YOUR_PASSWORD" port="YOUR_PORT">
    </smtp>
  </mailSettings>
</system.net>

Add this directly to the configuration root, changing the settings as appropriate. For testing purposes, you can use a Gmail account - which uses port 587. Be sure to double-check your settings! With that configured, when the user clicks the register button, they'll be sent an email with the confirmation link it, and they'll be redirected to the confirmation page. That page doesn't exist yet, so right click on the Views\Account folder and select Add | View. Name the view 'confirmation', and set your master page. The view does not need to be strongly typed, as you won't be rendering any data here (though you certainly could). Add a simple message like this:

<@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
                                 Inherits="System.Web.Mvc.ViewPage<dynamic>>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    Confirmation
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Confirmation</h2>
    <p>Thank you for registering. Please check your email for a confirmation request with a link that will confirm your account. Once you click the link, your registration will be complete.</p>

</asp:Content>

While you're at it, you also need to add corresponding action methods for the Confirmation and Welcome views:

public ActionResult Confirmation()
{
    return View();
}

public ActionResult Welcome()
{
    return View();
}

The welcome view don't exist yet, we'll add it closer to the end. At this point, everything should be good - and you'll just need to implement the Verify action on the Account controller.

public ActionResult Verify(string ID)
{
    if (string.IsNullOrEmpty(ID) || (!Regex.IsMatch(ID,
                   @"[0-9a-f]{8}\-([0-9a-f]{4}\-){3}[0-9a-f]{12}")))
    {
        TempData["tempMessage"] = "The user account is not valid. Please try clicking the link in your email again."
        return View();
    }

    else
    {
        MembershipUser user = Membership.GetUser(new Guid(ID));

        if (!user.IsApproved)
        {
            user.IsApproved = true;
            Membership.UpdateUser(user);
            FormsService.SignIn(user.UserName, false);
            return RedirectToAction("welcome");
        }
        else
        {
            FormsService.SignOut();
            TempData["tempMessage"] = "You have already confirmed your email address... please log in.";
            return RedirectToAction("LogOn");
        }
     }
}

This takes the id passed in the querystring and checks to ensure that it's not null and that it's a valid GUID. You'll need to import the using System.Text.RegularExpressions; namespace for the regex to work. Once those criteria are met, the code instantiates a MembershipUser with the ID by calling the GetUser method of the Membership class. The code then checks if the user is not currently approved, and in that case, sets the IsApproved property to true, updates the User object store, signs the user in and redirects the user to a 'welcome' page. If the user is already approved (meaning this is not the first time the user has clicked the link), it signs any user out and redirects the user to the LogOn page, adding a message to TempData that the account is already registered. This last part is optional, and I'm not sure what the 'proper' experience should be for a user that clicks on the link multiple times - but this seemed to work for me. The last thing you'll need to do is add the welcome view, again, right-clicking on Views\Account and selecting Add | View:

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
                                 Inherits="System.Web.Mvc.ViewPage<dynamic>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    Welcome
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Welcome</h2>

    <p>
        Thank you <%: User.Identity.Name %> for verifying your email address.
                          You now have access to the site, and can poke around as you please.
    </p>

</asp:Content>

With this done, you should have a workflow as follows: When the user clicks on the register link, they enter their registration data: register When they click the register button, they're redirected to the confirmation page with instructions to check their email: confirmation When they open their email client, they'll have an email containing their verification link: email Finally, clicking the link will verify their account, sign them in, and redirect them to the welcome page: welcome Now, I could have chosen to just verify the account and then redirect them to the sign-on page. But that's always annoyed me, and since there isn't money on the line, I decided to be a nice guy and save my user from the hassle of having to do more work. I hate it when I call the phone company and have to enter all of my data - only to end up talking to an operator whose first question is: "And may I have your account number please?" Umm... didn't I just type that in? If the user tries to confirm their account more than once, they're taken to: duh This requires that we check for the existence of tempData in the Accounts\LogOn view:

<asp:Content ID="loginContent" ContentPlaceHolderID="MainContent" runat="server">
    <h2>Log On</h2>
    <p>
        Please enter your username and password.
                <%: Html.ActionLink("Register", "Register") %> if you don't have an account.
    </p>
    <%  if (!string.IsNullOrEmpty(TempData["tempMessage"] as string)) { %>
        <p>
            <b class="error"><%: TempData["tempMessage"].ToString() %></b>
        </p>
    <% } %>

    <% using (Html.BeginForm()) { %>

Wrap Up

That's all there is to it. I've included a working sample application here. I didn't include the database, as it will be created for you in the App_Data folder when you register your first user. Please let me know if you have any questions or issues.

Kevin Sherman

I do stuff and things

Cleveland, OH