Everything you need to know about MembershipProvider
Heads Up!
This article is several years old now, and much has happened since then, so please keep that in mind while reading it.
Let's then take a closer look at Membership in Umbraco. Out of the box we got a great solution that would cover most of scenarios in web projects. There are custom member types with customizable properties and member groups, which can be used to set permissions at the node level. All of that is explained very well in the official Umbraco Members documentation by showing how to create and edit members in Backoffice.
Last year Lee wrote great article about "Dealing With Members Using The MemberService & MembershipHelper". It's a very good place to start your journey and get to know how to query, register and update members straight from the code using MemberService and MembershipHelper. But let's look a little bit deeper and check what's going on under the hood.
As you may already know, one of the greatest parts of Umbraco is the possibility to extend or replace almost all built-in features. One of them is the Membership Provider which we will going to explore today.
Below you can find small agenda of this post:
- Simple Log in form
- Extending default provider by Two-factor authentication
- Replacing default MembershipProvider
- Replacing default RoleProvider
- Summary
Simple Log in form
Ok, so we have members created in Backoffice or in the code but it would be nice to let them actually log in and get access to "Members only area". To do this we need one surface controller and one view.
public class MembersController : SurfaceController
{
[HttpPost]
public ActionResult HandleLogin(string username, string password)
{
if (!Members.Login(username, password))
{
TempData["status"] = "Login failed";
}
return RedirectToCurrentUmbracoUrl();
}
}
@using MembersProject.Controllers
@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@{
Layout = "Master.cshtml";
}
<h1> @CurrentPage.Name </h1>
@if (Members.IsLoggedIn())
{
// if member is already logged in make redirect to /member-area
Response.Redirect("/member-area");
}
else
{
using (Html.BeginUmbracoForm<MembersController>("HandleLogin", null))
{
<div class="form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="Username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="Password">
</div>
<div class="form-group">
<button type="submit" class="btn btn-default">Login</button>
@if (TempData["Status"] != null)
{
<p>
@TempData["Status"]
</p>
}
</div>
</div>
}
}
And that's it, members can now log into our website. Simple, right ?
To make it complete we also need a Logout option. We will do it by creating Logout template as follows:
@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@{
Layout = "Master.cshtml";
}
@if (Members.IsLoggedIn())
{
Members.Logout();
<h2>Logout successful</h2>
<a href="/Login">Login</a>
}
else
{
//if members is not logged in make redirect to /Logion page
Response.Redirect("/Login");
}
Now members can log in and log out from our website. That's cool but not cool enough to open champagne.
Let's see what we can do to make it little bit more complicated :)
Extending default provider by Two-factor authentication
If you take a look at MemberController in Umbraco sources, you can find that during login process it's calling ValidateUser method of current MembershipProvider. This means if we create our MembershipProvider inherited from original one we can override the ValidateUser method and add some custom code there. Don't be scared, it's really simple and I hope the comments put some light there.
public class ExtendedMembershipProvider : MembersMembershipProvider
{
public override bool ValidateUser(string username, string password)
{
var session = HttpContext.Current.Session;
//check what login step it is
var loginStep = session["loginStep"] != null ? session["loginStep"].ToString() : "1";
if (loginStep == "1")
{
if (base.ValidateUser(username, password))
{
//member is validated so set logiStep to 2
session["loginStep"] = "2";
//generate PIN number and store it in Session object
var pin = SecurityHelper.GeneratePinNumber();
session["currentPin"] = pin;
// store member username for later use
session["currentMember"] = username;
//send PIN to member via Email or SMS, we will just output it to tracelog
LogHelper.Info<ExtendedMembershipProvider>("PIN number for member {0} is {1}", () => username,
() => pin);
//although Member is validated return false because PIN is not yet provided
return false;
}
}
else
{
var currentPin = session["currentPin"] != null
? session["currentPin"].ToString()
: string.Empty;
//in this case password is PIN
if (currentPin == password)
{
// clear all values stored in session
session["currentPin"] = null;
session["loginStep"] = null;
session["currentMember"] = null;
return true;
}
}
return false;
}
}
Just little changes in HandleLogin and Login view and we are ready to go.
public ActionResult HandleLogin(string username, string password)
{
var loginStep = Session["loginStep"];
// if it's second step get username from session as we are getting only PIN
if (loginStep != null && loginStep.ToString() == "2")
{
username = Session["currentMember"] != null ? Session["currentMember"].ToString() : string.Empty;
}
if (!Members.Login(username, password))
{
// if we are still on the same step and Member validation failed that means we need to dispaly login error
if (loginStep == Session["loginStep"])
{
TempData["status"] = "Login failed";
}
}
return RedirectToCurrentUmbracoUrl();
}
@using MembersProject.Controllers
@inherits Umbraco.Web.Mvc.UmbracoTemplatePage
@{
Layout = "Master.cshtml";
var step = Session["loginStep"] != null ? Session["loginStep"].ToString() : "1";
}
<h1> @CurrentPage.Name </h1>
@if (Members.IsLoggedIn())
{
// if member is already logged in make redirect to /member-area
Response.Redirect("/member-area");
}
else
{
using (Html.BeginUmbracoForm<MembersController>("HandleLogin", null))
{
//display correct step header
if (step == "2")
{
<h2>Step 2</h2>
}
else
{
<h2>Step 1</h2>
}
<div class="form">
@if (step == "2")
{
// if it's step 2 display just PIN field
<div class="form-group">
<label for="password">PIN</label>
<input type="password" class="form-control" id="password" name="Password">
</div>
}
else
{
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="Username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="Password">
</div>
}
<div class="form-group">
<button type="submit" class="btn btn-default">Login</button>
@if (TempData["Status"] != null)
{
<p>
@TempData["Status"]
</p>
}
</div>
</div>
}
}
If you are curious how PIN is generated, here is the content of "magic box":
public static class SecurityHelper
{
private static readonly Random Rand = new Random();
public static string GeneratePinNumber()
{
return Rand.Next(1000, 9999).ToString();
}
}
Ok, but let's just sum up what's going on above. We've overridden the ValidateUser method to hold Member validation until the correct PIN is provided. PIN is stored in Session object to keep this example simple. For the same reason, the generated PIN is outputted to UmbracoTraceLog instead of SMS, Email or any other way.
One final step to make it work is replacing MembersMembershipProvider by ExtendedMembershipProvider in web.config.
<add name="UmbracoMembershipProvider" type="MembersProject.Extensions.ExtendedMembershipProvider, MembersProject" minRequiredNonalphanumericCharacters="0" minRequiredPasswordLength="8" useLegacyEncoding="true" enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" defaultMemberTypeAlias="Member" passwordFormat="Hashed"/>
Now when you try to log in you will be asked for PIN number and you can find it in App_Data\Logs\UmbracoTraceLog.txt
Voilà! We've just created (very simple) two-factor authentication.
Replacing default MembershipProvider
This is not the only thing we can do with MembershipProvider in Umbraco. Sometimes there are situations where Members are stored in another table, database or even in some remote service. Even in scenarios like this we can still use Umbraco Backoffice to handle website members just by replacing MembershipProvider. Let's take a look. This time we need to create CustomMembershipProvider inherited straight from MembershipProvider class. Otherwise Umbraco will recognize it as extension to original provider and won't let you take total control over members.
public class CustomMembershipProvider : MembershipProvider
{
}
MembershipProvider is an abstract class so by itself it will force you to override a bunch of methods. We will focus only on those that let us display members in the member tree and pass the validation process.
Script for creating CustomMembers table as well as whole working example you can find in Github repo which will be listed at the end of this post.
Before all let's just make simple MembersRepository using PetaPoco to grab members from our custom table.
public class CustomMember
{
public Guid Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string Comment { get; set; }
public bool IsApproved { get; set; }
public bool IsLockedOut { get; set; }
public DateTime LastLoginDate { get; set; }
public DateTime LastLockoutDate { get; set; }
public bool IsFrontendDev { get; set; }
public bool IsBackendDev { get; set; }
}
public class MembersRepository
{
private readonly UmbracoDatabase _db;
public MembersRepository()
{
_db = ApplicationContext.Current.DatabaseContext.Database;
}
public List<CustomMember> GetMembers(int pageIndex, int pageSize, out int totalRecords)
{
totalRecords = _db.ExecuteScalar<int>("SELECT Count(*) FROM CustomMembers");
var members = _db.SkipTake<CustomMember>(pageIndex * pageSize, pageSize, "SELECT * FROM CustomMembers");
return members;
}
public CustomMember GetMemberById(Guid id)
{
var member = _db.Single<CustomMember>("SELECT TOP 1 * FROM CustomMembers WHERE Id = @0", id);
return member;
}
public List<CustomMember> GetMembersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
{
totalRecords = _db.ExecuteScalar<int>("SELECT Count(*) FROM CustomMembers WHERE Email = @0", emailToMatch);
var members = _db.SkipTake<CustomMember>(pageIndex * pageSize, pageSize, "SELECT * FROM CustomMembers WHERE Email = @0", emailToMatch);
return members;
}
public CustomMember GetMemberByUsername(string usernameToMatch)
{
var member = _db.Single<CustomMember>("SELECT TOP 1 * FROM CustomMembers WHERE Username = @0", usernameToMatch);
return member;
}
public List<CustomMember> GetMembersByUsername(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords)
{
totalRecords = _db.ExecuteScalar<int>("SELECT Count(*) FROM CustomMembers WHERE Username = @0", usernameToMatch);
var members = _db.SkipTake<CustomMember>(pageIndex * pageSize, pageSize, "SELECT * FROM CustomMembers WHERE Username = @0", usernameToMatch);
return members;
}
}
Now we can go back to CustomMembershipProvider and have a look on methods we need to override.
public class CustomMembershipProvider : MembershipProvider
{
private readonly MembersRepository _membersRepository;
public CustomMembershipProvider()
{
_membersRepository = new MembersRepository();
}
public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
{
var members = _membersRepository.GetMembersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords);
var membersCollection = new MembershipUserCollection();
foreach (var customMember in members)
{
membersCollection.Add(ConvertToMembershipUser(customMember));
}
return membersCollection;
}
public override MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords)
{
var members = _membersRepository.GetMembersByUsername(usernameToMatch, pageIndex, pageSize, out totalRecords);
var membersCollection = new MembershipUserCollection();
foreach (var customMember in members)
{
membersCollection.Add(ConvertToMembershipUser(customMember));
}
return membersCollection;
}
public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
{
var members = _membersRepository.GetMembers(pageIndex, pageSize, out totalRecords);
var membersCollection = new MembershipUserCollection();
foreach (var customMember in members)
{
membersCollection.Add(ConvertToMembershipUser(customMember));
}
return membersCollection;
}
public override MembershipUser GetUser(string username, bool userIsOnline)
{
var member = _membersRepository.GetMemberByUsername(username);
if (member != null)
{
return ConvertToMembershipUser(member);
}
return null;
}
public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
{
Guid id;
if (Guid.TryParse(providerUserKey.ToString(), out id))
{
var member = _membersRepository.GetMemberById(id);
if (member != null)
{
return ConvertToMembershipUser(member);
}
}
return null;
}
public override bool ValidateUser(string username, string password)
{
var member = _membersRepository.GetMemberByUsername(username);
if (member != null)
{
//This is only exmaple, avoid storing passwords in plain text :)
return member.Password == password;
}
return false;
}
private MembershipUser ConvertToMembershipUser(CustomMember member)
{
return new MembershipUser("UmbracoMembershipProvider", member.Username, member.Id, member.Email, "", "", member.IsApproved, member.IsLockedOut, DateTime.Now, DateTime.Now, DateTime.Now, DateTime.MinValue, DateTime.MinValue);
}
}
Pretty much code, but seriously nothing very complicated when you look at it closer. We are just using created MembersRepository to get members from database with different conditions. You can also see ValidateUser method there. Similar to previous example, this method will be hit every time member is trying to log in. Other point of interest could be ConvertToMembershipUser method which returns new instance of MembershipUser based on given CustomMember object.
To make it work, we need to just replace MembershipProvider declaration in web.config.
<add name="UmbracoMembershipProvider" type="MembersProject.Extensions.CustomMembershipProvider, MembersProject" minRequiredNonalphanumericCharacters="0" minRequiredPasswordLength="8" useLegacyEncoding="true" enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" defaultMemberTypeAlias="Member" passwordFormat="Hashed"/>
That's all, no need to make any changes in log in method or in backoffice views.
Replacing default RoleProvider
Last topic I would like to address is RoleProvider. For now it's not possible to combine default Umbraco MembersRoleProvider with totally custom MembershipProvider, so we need to create also custom RoleProvider. But don't worry, it's very easy, as you will see, very flexible. Again, I won't list here all methods that needs to be overridden but only those that are crucial for this example.
public class CustomRoleProvider : RoleProvider
{
private readonly MembersRepository _membersRepository;
public CustomRoleProvider()
{
_membersRepository = new MembersRepository();
}
public override string[] GetAllRoles()
{
//we are using static array of roles but it can be db or service query as well
return new string[] { "Frontend Devs", "Backend Devs" };
}
public override string[] GetRolesForUser(string username)
{
var member = _membersRepository.GetMemberByUsername(username);
var memberRoles = new List<string>();
if (member != null)
{
if (member.IsBackendDev)
{
memberRoles.Add("Backend Devs");
}
if (member.IsFrontendDev)
{
memberRoles.Add("Frontend Devs");
}
}
return memberRoles.ToArray();
}
public override bool IsUserInRole(string username, string roleName)
{
var member = _membersRepository.GetMemberByUsername(username);
switch (roleName)
{
case "Backend Devs":
return member.IsBackendDev;
case "Frontend Devs":
return member.IsFrontendDev;
}
return false;
}
}
It's very, very simple. Roles are defined by static strings array and we are using IsBackendDev and IsFrondendDev fields, that you may noticed before, to determine member roles. In your further experiments you can query another table in database or some external web service to get member roles but for now let's stay with static values.
By changing one line in web.config we can put whole machine to work.
<add name="UmbracoRoleProvider" type="MembersProject.Extensions.CustomRoleProvider"/>
Let's see what just happened. Now we have our new roles listed in Umbraco Backoffice under Member Groups node. Also Member roles are representing what we actually have in database.
See that in db and in Backoffice Marcin is marked as both Frontend and Backend dev.
But it's not all. Also "Public access" option is working great with our new roles.
Amazing, isn't it ?
Summary
With few simple steps (and lots of simple code :)) we manage to, firstly, extend default Umbraco MembershipProvider and next totally replace it with a custom one. It's not rocket science but I believe it can be useful for people looking for some extra Membership features. It also proves that Umbraco is very flexible and even when working with custom members data, it's still possible to take advantage of awesome Backoffice without creating custom sections.
Further steps? I encourage you to play a little bit with solution you can find on Github and finish unfinished methods for Creating and Updating Members and Roles. Together with exploring Umbraco sources it could be a great excercise for testing possibilities of our favourite CMS :)
If you have any questions please feel free to ask, I will be very happy to answer all of them.
Resources
Pawel Bres
Pawel is on Twitter as @pawelbres