Disclosure: answer taken from Securing the password recovery experience in Sitecore 8 by Anders Laub Christofferson
First we do some configuration re-organizing.
We copy paste the old passwordRecovery
pipeline and call the new one confirmedPasswordRecovery
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<processors>
<confirmPasswordRecovery argsType="Sitecore.Pipelines.PasswordRecovery.PasswordRecoveryArgs">
<processor mode="on" type="Sitecore.Pipelines.PasswordRecovery.VerifyUsername, Sitecore.Kernel" />
<processor mode="on" type="Sitecore.Pipelines.PasswordRecovery.GeneratePassword, Sitecore.Kernel" />
<processor mode="on" type="Sitecore.Pipelines.PasswordRecovery.PopulateMail, Sitecore.Kernel" />
<processor mode="on" type="Sitecore.Pipelines.PasswordRecovery.SendPasswordRecoveryMail, Sitecore.Kernel" />
</confirmPasswordRecovery>
</processors>
</sitecore>
</configuration>
We replace the GeneratePassword
with a processor called GenerateToken
for generating and storing a token on the user profile:
public class GenerateToken : PasswordRecoveryProcessor
{
public override void Process(PasswordRecoveryArgs args)
{
Assert.ArgumentNotNull(args, "args");
var user = User.FromName(args.Username, true);
if (user == null)
{
args.AbortPipeline();
return;
}
var token = ID.NewID.ToShortID().ToString();
StoreTokenOnUser(user, token);
args.CustomData.Add(Constants.ConfirmTokenKey, token);
}
private void StoreTokenOnUser(User user, string confirmToken)
{
user.Profile.SetCustomProperty(Constants.ConfirmTokenKey, confirmToken);
user.Profile.Save();
}
}
To make this work you need to add a field called PasswordToken on the user profile
internal struct Constants
{
internal const string ConfirmTokenKey = "PasswordToken";
}
And then we replace PopulateMail
with PopulateConfirmMail
that populates a html email with a confirm link.
public class PopulateConfirmMail : PopulateMail
{
public override void Process(PasswordRecoveryArgs args)
{
Assert.ArgumentNotNull(args, "args");
var token = args.CustomData[Constants.ConfirmTokenKey] as string;
if (string.IsNullOrEmpty(token))
return;
var confirmLink = GenerateConfirmLink(token, args.Username);
args.SendFromDisplayName = "Sitecore website";
args.SendFromEmail = "[email protected]";
args.Subject = "Confirm password recovery";
var user = User.FromName(args.Username, false);
args.HtmlEmailContent = GetHtmlEmailContent(user, confirmLink);
}
protected virtual string GenerateConfirmLink(string token, string userName)
{
var serverUrl = StringUtil.EnsurePostfix('/', WebUtil.GetServerUrl());
return serverUrl + "sitecore/api/passwordrecovery/confirm/" + userName.Replace('\\', '|') + "/" + token;
}
protected virtual string GetHtmlEmailContent(User user, string confirmLink)
{
var sb = new StringBuilder();
sb.AppendLine("<html><head><title>");
sb.AppendLine("Sitecore password recovery");
sb.AppendLine("</title></head><body>");
sb.AppendLine("<h1>Please confirm</h1>");
sb.AppendLine("<p>Hi " + user.Profile.FullName + ",<br/></p>");
sb.AppendLine("<p>Please follow the link below to recover your password</p>");
sb.AppendLine("<a href=\"" + confirmLink + "\">" + confirmLink + "</a>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
}
Notice the confirm link that we create, this is for a ApiController
with a HttpGet
method that expects a username and a token.
public class ConfirmRecoveryController : ApiController
{
[HttpGet]
public IHttpActionResult Confirm(string userName, string token)
{
userName = userName.Replace('|', '\\');
var user = Sitecore.Security.Accounts.User.FromName(userName, true);
if (user == null || !TokenIsValid(user, token))
return new StatusCodeResult(HttpStatusCode.Unauthorized, this);
var passwordRecoveryArgs = new PasswordRecoveryArgs(HttpContext.Current)
{
Username = userName
};
Pipeline.Start("confirmPasswordRecovery", passwordRecoveryArgs);
if (!passwordRecoveryArgs.Aborted)
DeleteToken(user);
return new StatusCodeResult(HttpStatusCode.OK, this);
}
private void DeleteToken(User user)
{
user.Profile.SetCustomProperty(Constants.ConfirmTokenKey, string.Empty);
user.Profile.Save();
}
private bool TokenIsValid(User user, string token)
{
return !string.IsNullOrEmpty(token) && ShortID.IsShortID(token) && TokenExists(user, token);
}
private bool TokenExists(User user, string confirmToken)
{
var tokenOnProfile = user.Profile.GetCustomProperty(Constants.ConfirmTokenKey);
return !string.IsNullOrEmpty(tokenOnProfile) && tokenOnProfile.Equals(confirmToken, StringComparison.InvariantCultureIgnoreCase);
}
}
This controller executes the new (old) confirmPasswordRecovery
pipeline, then deletes the token and redirects to the login page.
We need to register the route so we create a processor for the initialize pipeline:
public class RegisterHttpRoutes
{
public void Process(PipelineArgs args)
{
GlobalConfiguration.Configure(Configure);
}
protected void Configure(HttpConfiguration configuration)
{
var routes = configuration.Routes;
routes.MapHttpRoute("PasswordRecovery_Confirm", "sitecore/api/passwordrecovery/{action}/{userName}/{token}", new
{
controller = "ConfirmRecovery",
action = "Index"
});
}
}
And patch it into the initialize pipeline:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<pipelines>
<initialize>
<processor patch:after="processor[@type='Sitecore.Pipelines.Loader.EnsureAnonymousUsers, Sitecore.Kernel']"
type="LaubPlusCo.PasswordRecovery.Infrastructure.RegisterHttpRoutes, LaubPlusCo.PasswordRecovery" />
</initialize>
</pipelines>
</sitecore>
</configuration>
And the password recovery mail is the standard one that you can modify in the core database on the item /sitecore/system/Settings/Security/Password recovery/Password Recovery Email
Link to blog post here
Update:
If you want to have token expiration functionality, you need to add to Custom User Profile new field like Created
, where you will store token created DateTime
value and when user will click link in email, you need to check this value with your condition.