Adding Email Confirmation to ASP.NET MVC
Leave a reply
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).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| [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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| [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:
1
2
3
4
5
6
7
8
9
10
11
| 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| 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
1
2
| using System.Net.Mail; using System.Configuration; |
namespaces, and you’ll need to add the following to your web.config file:
1
2
3
4
5
6
7
8
| <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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <@ 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:
1
2
3
4
5
6
7
8
9
| 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| 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
1
| 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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <%@ 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:
When they click the register button, they’re redirected to the confirmation page with instructions to check their email:
When they open their email client, they’ll have an email containing their verification link:
Finally, clicking the link will verify their account, sign them in, and redirect them to the welcome page:
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:
This requires that we check for the existence of tempData in the Accounts\LogOn view:
1
2
3
4
5
6
7
8
9
10
11
12
13
| <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()) { %> |