Extending ASP.NET MVC Account Registration with Workflow (WF4)

One thing that many web sites do is to verify email addresses by sending you an email to complete registration.  I decided to build a Registration system for ASP.NET MVC using Windows Workflow Foundation.

When you create a new ASP.NET MVC web site, the site comes with a simple account controller that integrates with ASP.NET Membership.  It provides basic one step registration and log-in support.  I wanted to take this much farther and provide a simple self-contained registration verification system.

Download the Sample Code from MSDN Code Gallery

Scenarios

When I plan work like this, my first step is to prepare the list of scenarios I'm working on so I don't get distracted and don't miss anything important

Given When Then

A user registers for the site with a valid email address

The user clicks on Register

  • The user is added to the Membership database with the isApproved flag set to false
  • A Workflow is started to manage the membership verification
  • The workflow sends an email to the members email address

A user receives the email verification message

The user clicks the link in the verification email
  • A browser launches and opens the Verification page providing a verificationCode in the URL query string
  • The Workflow is loaded and resumed with the confirmation command
  • The membership is approved
A user attempts to log-in after registration but before the verification email is confirmed The user clicks on log in
  • The Membership is found in the membership database
  • Because the isApproved flag is false, an error is generated including a link to the page to re-send the verification email
A user cannot find the verification email and wants it sent again. The user navigates to the site, enters username and password and clicks on log in then clicks on the link in the error message to navigate to the re-send confirmation page The user clicks re-send to send the message again
  • The Workflow instanceId for the email is located using a Promoted Property. If not found, an error is displayed for an invalid email address
  • The Workflow is loaded and the send mail command is resumed
After registration, the user fails to click on the link in the confirmation mail The timeout interval expires
  • The Workflow with an expired timer is detected and the workflow is loaded
  • The timeout action increments a counter and a second, third or fourth message can be sent to the user
  • If the timeout has exceeded the maximum number of timeouts, the user account is deleted
After registration, the user decides to cancel registration The user clicks the cancel link in the email
  • The Workflow is loaded
  • The workflow is resumed with the cancel command
  • The user account is deleted

Implementation

For my platform, I chose Visual Studio 11, .NET 4.5 and ASP.NET MVC 4.  However the same concepts will work fine with .NET 4.0 and MVC 3 given a few minor modifications.

Step 1 - Creating users with isApproved False

For this step I simply searched account controller for isApproved. I found that by default when users are created, isApproved is set to true.  In MVC 4 there are two methods that create users they are Register and JsonRegister. The modification is shown below.

  1. /// <summary>
  2. /// Provides registration support for the registration pop-up dialog
  3. /// </summary>
  4. /// <param name="model">The model</param>
  5. /// <returns>An action result</returns>
  6. /// <remarks>
  7. /// The default implementation of this method creates and automatically approves users. In this case we don't want to approve a user until their email is verified. 
  8. /// The default implementation also implicitly logs in the created user. In this case we do not want to log in the newly created user.
  9. /// </remarks>
  10. [AllowAnonymous]
  11. [HttpPost]
  12. public ActionResult JsonRegister(RegisterModel model)
  13. {
  14.     if (this.ModelState.IsValid)
  15.     {
  16.         // Attempt to register the user
  17.         MembershipCreateStatus createStatus;
  18.  
  19.         // TODO: Notice how we set isApproved = false until email verification is complete
  20.         Membership.CreateUser(
  21.             model.UserName,
  22.             model.Password,
  23.             model.Email,
  24.             passwordQuestion: null,
  25.             passwordAnswer: null,
  26.             isApproved: false,
  27.             providerUserKey: null,
  28.             status: out createStatus);
  29.  
  30.         if (createStatus == MembershipCreateStatus.Success)
  31.         {
  32.             // TODO: Notice how we do not log in here but start the verification process
  33.             this.VerifyRegistration(model);
  34.  
  35.             // TODO: Notice how we redirect to the confirmation page
  36.             return this.Json(new { success = true, redirect = this.Url.Action("Confirmation") });
  37.         }
  38.         this.ModelState.AddModelError("", ErrorCodeToString(createStatus));
  39.     }
  40.  
  41.     // If we got this far, something failed
  42.     return this.Json(new { errors = this.GetErrorsFromModelState() });
  43. }

Step 2: Sending an Email

For this step I’m going to need an activity that can send email and I want to supply a nicely formatted HTML email with the username embedded and an absolute URL to the Site.css stylesheet.  For this example, I decided to create a SendMail activity that uses file based email templates.  This allows me to treat the body of the HTML mail as content from the site perspective.  To improve performance I cache the HTML files after they are read and check to see if the source file has changed before using a cached copy.

Using SmtpClient with AsyncCodeActivity was a particular challenge because the SmtpClient class uses an event based async model (EAP) and it took me a while to work out how to use a TaskCompletionSource with AsyncCodeActivity. Take a look at the SendMail.cs file for more details.

In the body of the email, I will have to include an absolute URL to the verification page including a verificationCode which is simply the InstanceId of the workflow.  Given the enormous amount of data that can apply to an email message, I decided to create a type to pass between the MVC code and the Workflow which contains everything I need.

Problem: HTML Email requires fully qualified URLs

I want the HTML email to have links which must be fully qualified. I need links to the Site.css file so I can take advantage of styling in the email and the verification URL.  To do this, I created the some extension methods to the UrlHelper class

  1. public static class UrlHelperExtensions
  2. {
  3.     #region Public Methods and Operators
  4.  
  5.     public static string FullyQualifiedAction(this UrlHelper urlHelper, string actionName, string controllerName)
  6.     {
  7.         return FullyQualify(
  8.             urlHelper.RequestContext.HttpContext.Request.Url, urlHelper.Action(actionName, controllerName));
  9.     }
  10.  
  11.     public static string FullyQualifiedAction(this UrlHelper urlHelper, string actionName)
  12.     {
  13.         return FullyQualify(urlHelper.RequestContext.HttpContext.Request.Url, urlHelper.Action(actionName));
  14.     }
  15.  
  16.     public static string FullyQualifiedContent(this UrlHelper urlHelper, string contentPath)
  17.     {
  18.         return FullyQualify(urlHelper.RequestContext.HttpContext.Request.Url, urlHelper.Content(contentPath));
  19.     }
  20.  
  21.     #endregion
  22.  
  23.     #region Methods
  24.  
  25.     private static string FullyQualify(Uri requestUri, string uri)
  26.     {
  27.         return new UriBuilder(requestUri.Scheme, requestUri.Host, requestUri.Port, uri).Uri.ToString();
  28.     }
  29.  
  30.     #endregion
  31. }

Now when I want to get the fully qualified URL it is very simple

  1. // Created extension methods to provide fully qualified URLs for email
  2. VerificationUrl = this.Url.FullyQualifiedAction("Verification"),
  3. CancelUrl = this.Url.FullyQualifiedAction("Cancel"),
  4. StylesUrl = this.Url.FullyQualifiedContent("~/Content/Site.css"),
Problem: How to merge arguments into the HTML email

In the HTML email I want to merge two kinds of arguments.  Some are supplied by the calling code in the BodyArguments array and some are generated automatically.  The automatically generated elements can be referred to by name.

  1. <!DOCTYPE html>
  2. <html xmlns="https://www.w3.org/1999/xhtml">
  3. <head>
  4.     <title>Thanks for Registering</title>
  5.     <link rel="stylesheet" type="text/css" href="{{StylesUrl}}" />
  6. </head>
  7. <body>
  8.     <div class="featured">
  9.         <hgroup class="title">
  10.             <h1>All most finished...</h1>
  11.         </hgroup>
  12.         <p>
  13.             Thanks for registering with us {0}, Please complete your registration by clicking
  14.             <a href="{{VerificationUrl}}">here</a>.
  15.             To cancel your registration, click <a href="{{CancelUrl}}">here</a>
  16.         </p>
  17.     </div>
  18. </body>
  19. </html>

To keep the SendMail activity very generic, I moved the formatting of the message and merging of the arguments into the FormatMailBody activity.  As you can see, when I want to use a generated value in the email such as the stylesheet URL in line 5 I place the keyword inside of double braces.  If I want to refer to one of the Body arguments that my code created, I just use the typical positional references as in line 13.

Step 3: Run the Workflow

Rather than ask the MVC developer to become an expert on WorkflowApplication, I created a helper class which accepts the Workflow type that you want to use as a template parameter.  This allowed me to put in place a simple strongly typed API and hide the details of Workflow.  For the Workflow, I’ve created a StateMachine that does everything I need.  Of course, you can make the workflow more complex if you want.  I can imagine scenarios where a Human might have to approve membership or perhaps there is a membership fee that must be collected, any of these things can be provided for in the StateMachine.

image

  1. private void VerifyRegistration(RegisterModel model)
  2. {
  3.     // Create a verification workflow using the helper
  4.     var workflow = new RegistrationVerification<AccountRegistration>();
  5.  
  6.     Debug.Assert(this.Request.Url != null, "Request.Url != null");
  7.  
  8.     // TODO: Notice how we setup the registration system with sever email templates
  9.     workflow.VerifyRegistration(
  10.         new RegistrationData
  11.             {
  12.                 // HTML files created by Visual Studio are UTF8 encoded
  13.                 BodyEncoding = Encoding.UTF8,
  14.                 // These arguments can be used to insert data into the HTML mail template
  15.                 BodyArguments = new[] { model.UserName },
  16.                 // These templates will be used in-order to provide email reminders. After the
  17.                 // last email, the verification process will give up and delete the account
  18.                 EmailTemplates =
  19.                     new[]
  20.                         {
  21.                             this.Server.MapPath("~/Content/RegistrationVerificationEmail.html"),
  22.                             this.Server.MapPath("~/Content/Reminder1Email.html"),
  23.                             this.Server.MapPath("~/Content/FinalReminderEmail.html") },
  24.                 IsBodyHtml = true,
  25.                 // TODO: Modify email properties as required
  26.                 From = "todo@tempuri.org",
  27.                 Sender = "todo@tempuri.org",
  28.                 Subject = "Registration almost complete",
  29.                 To = new[] { model.Email },
  30.                 // Created extension methods to provide fully qualified URLs for email
  31.                 VerificationUrl = this.Url.FullyQualifiedAction("Verification"),
  32.                 CancelUrl = this.Url.FullyQualifiedAction("Cancel"),
  33.                 StylesUrl = this.Url.FullyQualifiedContent("~/Content/Site.css"),
  34.                 UserName = model.UserName,
  35.                 UserEmail = model.Email,
  36.             });
  37. }

And of course, I’ve added support for Debug Tracing of the Workflow as it executes using Microsoft.Activities.Extensions.  In the VS Debug window when the Workflow runs you will see nicely formatted trackiing information to help you.

 41: Activity [1.34] "SendMail" scheduled child activity [1.90] "Wait For Confirmation"
42: Activity [1.34] "SendMail" scheduled child activity [1.62] "Sequence"
43: Activity [1.34] "SendMail" scheduled child activity [1.49] "Wait For Resend Command"
44: Activity [1.49] "Wait For Resend Command" is Executing
{
    Arguments
        Command: SendMail
}
45: Activity [1.62] "Sequence" is Executing
46: Activity [1.62] "Sequence" scheduled child activity [1.76] "Delay"
Problem – How to wait for a command to complete registration

I like to create an enum that declares the set of commands that I’m going to use for my Workflow.  In this case, there are a few simple commands.

  1. public enum RegistrationCommand
  2. {
  3.     SendMail,
  4.     Confirm,
  5.     Cancel,
  6. }

Then, I used the same technique that I demonstrated in the Introduction To StateMachine Hands On Lab.  I created an activity which waits for a command using the enum as the bookmark name.

Problem – How to monitor workflows with expired timers

For this example, I have decided against using Windows Server AppFabric because I want to (eventually) run this on Windows Azure.  However it is quite simple to plug in the monitoring by launching a thread from the Application_Start method.

  1. protected void Application_Start()
  2. {
  3.     AreaRegistration.RegisterAllAreas();
  4.  
  5.     // Use LocalDB for Entity Framework by default
  6.     Database.DefaultConnectionFactory =
  7.         new SqlConnectionFactory(
  8.             "Data Source=(localdb)\v11.0; Integrated Security=True; MultipleActiveResultSets=True");
  9.  
  10.     RegisterGlobalFilters(GlobalFilters.Filters);
  11.     RegisterRoutes(RouteTable.Routes);
  12.  
  13.     // TODO: Notice how we monitor registrations with durable timers
  14.     RegistrationVerification<AccountRegistration>.MonitorRegistrations();
  15.  
  16.     BundleTable.Bundles.RegisterTemplateBundles();
  17. }

Setup

Pre-Requisites

The sample code requires

Configuration

Just look for TODO in the web.config file to find things.

You will first need to create the Workflow Instance Store database.  I’ve provided some batch files to make things easier

CreateInstanceStore.cmd – Drops / Creates the instance store.  Close IISExpress prior to running this to close the connection.

Reset.cmd – Removes all users from the ASP.NET Membership store, removes / recreates c:\mailbox and re-creates the instance store. 

The email is configured to drop messages into c:\mailbox, however you can modify the config to use hotmail or your favorite email provider if you like.

The <appSettings> group includes two values

ReminderDelay – The timespan that the workflow will wait before sending a reminder email.  For testing you should make this a small value.  However keep in mind that after three reminders the account will be deleted so if you are debugging you should make this value longer.

InstanceDetectionPeriod – The number of seconds that the InstanceStore will wait before polling the database for changes.

Try It

  1. Press F5 to debug the app
  2. Register a new user
  3. Check the C:\Mailbox folder for an email message
  4. Open the message and click on the confirm link
  5. The registration will complete

Try other variations like not confirming or trying to log in before you have confirmed etc.