61

Does anyone know how to enable a user to change username/email with ASP.NET identity with email confirmation? There's plenty of examples on how to change the password but I can't find anything on this.

devlock
  • 959
  • 1
  • 9
  • 14

5 Answers5

63

Update Dec 2017 Some good points have been raised in comments:

  • Better have a separate field for new email while it is getting confirmed - in cases when user have entered incorrect email. Wait till the new email is confirmed, then make it the primary email. See very detailed answer from Chris_ below.
  • Also there could be a case when account with that email already exist - make sure you check for that too, otherwise there can be trouble.

This is a very basic solution that does not cover all possible combinations, so use your judgment and make sure you read through the comments - very good points have been raised there.

// get user object from the storage
var user = await userManager.FindByIdAsync(userId);

// change username and email
user.Username = "NewUsername";
user.Email = "New@email.com";

// Persiste the changes
await userManager.UpdateAsync(user);

// generage email confirmation code
var emailConfirmationCode = await userManager.GenerateEmailConfirmationTokenAsync(user.Id);

// generate url for page where you can confirm the email
var callbackurl= "http://example.com/ConfirmEmail";

// append userId and confirmation code as parameters to the url
callbackurl += String.Format("?userId={0}&code={1}", user.Id, HttpUtility.UrlEncode(emailConfirmationCode));

var htmlContent = String.Format(
        @"Thank you for updating your email. Please confirm the email by clicking this link: 
        <br><a href='{0}'>Confirm new email</a>",
        callbackurl);

// send email to the user with the confirmation link
await userManager.SendEmailAsync(user.Id, subject: "Email confirmation", body: htmlContent);



// then this is the action to confirm the email on the user
// link in the email should be pointing here
public async Task<ActionResult> ConfirmEmail(string userId, string code)
{
    var confirmResult = await userManager.ConfirmEmailAsync(userId, code);

    return RedirectToAction("Index");
}
trailmax
  • 34,305
  • 22
  • 140
  • 234
  • Glad you added the URL encoding as the stock Microsoft AspNet identity sample is broken and doesn't do this. – jakejgordon Oct 14 '14 at 16:56
  • 4
    I'd suggest you also log out the user so that they cannot continue via cookie-based authentication until they re-confirm their email: http://stackoverflow.com/questions/25878218/asp-net-identity-2-0-sign-out-another-user – BenjiFB Dec 10 '14 at 16:00
  • 12
    Wouldn't this approach cause trouble if the user enters a wrong/non-existing email? I would rather store the new email in a separate field, and update `Email` only after the confirmation is complete. – Geir Sagberg Mar 04 '15 at 07:56
  • Shouldn't this be done through the `UserManager` Class using the built in functions available? This way you will reduce issues when and if a schema change occurs? – Zapnologica Apr 29 '15 at 12:04
  • @Zapnologica I'm using `UserManager` to update the user details. What other functions do you refer to? – trailmax Apr 29 '15 at 13:16
  • 6
    What if the new email address is wrong and the username/email is set to that new email address, then the user cannot login anymore nor click the confirmation link.... –  Oct 26 '15 at 10:17
  • You should also check first to see if the account already exists. Then you need to merge accounts and cascade through any key updates. We often get customers with two accounts for `example.com` and `example.cmo` and need to merge them. If you don't check first you'll get in trouble. – Simon_Weaver Dec 18 '17 at 03:58
  • 1
    @Simon_Weaver good point. I've added an update to the answer saying this is not a complete solution. – trailmax Dec 18 '17 at 09:17
43

Trailmax got most of it right, but as the comments pointed out, the user would be essentially stranded if they were to mess up their new email address when updating.

To address this, it is necessary to add additional properties to your user class and modify the login. (Note: this answer will be addressing it via an MVC 5 project)

Here's where I took it:

1. Modify your User object First, let's update the Application User to add the additional field we'll need. You'll add this in the IdentiyModel.cs file in your Models folder:

public class ApplicationUser : IdentityUser
{
    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
    {
        // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
        var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
        // Add custom user claims here
        return userIdentity;
    }

    [MaxLength(256)]
    public string UnConfirmedEmail { get; set; }//this is what we add

}

If you want to see a more in depth example of that being done, check out this here http://blog.falafel.com/customize-mvc-5-application-users-using-asp-net-identity-2-0/ (that is the example I used)

Also, it doesn't mention it in the linked article, but you'll want to update your AspNetUsers table as well:

ALTER TABLE dbo.AspNetUsers
ADD [UnConfirmedEmail] NVARCHAR(256) NULL;

2. Update your login

Now we need to make sure our login is checking the old email confirmation as well so that things can be "in limbo" while we wait for the user to confirm this new email:

   //
    // POST: /Account/Login
    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return View(model);
        }

        var allowPassOnEmailVerfication = false;
        var user = await UserManager.FindByEmailAsync(model.Email);
        if (user != null)
        {
            if (!string.IsNullOrWhiteSpace(user.UnConfirmedEmail))
            {
                allowPassOnEmailVerfication = true;
            }
        }


        // This now counts login failures towards account lockout
        // To enable password failures to trigger account lockout, I changed to shouldLockout: true
        var result = await SignInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, shouldLockout: true);
        switch (result)
        {
            case SignInStatus.Success:
                return RedirectToLocal(returnUrl);
            case SignInStatus.LockedOut:
                return View("Lockout");
            case SignInStatus.RequiresVerification:
                return allowPassOnEmailVerfication ? RedirectToLocal(returnUrl) : RedirectToAction("SendCode", new { ReturnUrl = returnUrl, RememberMe = model.RememberMe });
            case SignInStatus.Failure:
            default:
                ModelState.AddModelError("", "Invalid login attempt.");
                return View(model);
        }
    }

That's it...you are essentially done! However, I always get annoyed by half answers that don't walk you past potential traps you'll hit later on, so let's continue our adventure, shall we?

3. Update your Manage/Index

In our index.cshtml, let's add a new section for email. Before we get there though, let's go add the field we need in ManageViewmodel.cs

public class IndexViewModel
{
    public bool HasPassword { get; set; }
    public IList<UserLoginInfo> Logins { get; set; }
    public string PhoneNumber { get; set; }
    public bool TwoFactor { get; set; }
    public bool BrowserRemembered { get; set; }

    public string ConfirmedEmail { get; set; } //add this
    public string UnConfirmedEmail { get; set; } //and this
}

Jump into the index action in our Manage controller to add that to our viewmodel:

        var userId = User.Identity.GetUserId();
        var currentUser = await UserManager.FindByIdAsync(userId);

        var unConfirmedEmail = "";
        if (!String.IsNullOrWhiteSpace(currentUser.UnConfirmedEmail))
        {
            unConfirmedEmail = currentUser.UnConfirmedEmail;
        }
        var model = new IndexViewModel
        {
            HasPassword = HasPassword(),
            PhoneNumber = await UserManager.GetPhoneNumberAsync(userId),
            TwoFactor = await UserManager.GetTwoFactorEnabledAsync(userId),
            Logins = await UserManager.GetLoginsAsync(userId),
            BrowserRemembered = await AuthenticationManager.TwoFactorBrowserRememberedAsync(userId),
            ConfirmedEmail = currentUser.Email,
            UnConfirmedEmail = unConfirmedEmail
        };

Finally for this section we can update our index to allow us to manage this new email option:

<dt>Email:</dt>
    <dd>
        @Model.ConfirmedEmail
        @if (!String.IsNullOrWhiteSpace(Model.UnConfirmedEmail))
        {
            <em> - Unconfirmed: @Model.UnConfirmedEmail </em> @Html.ActionLink("Cancel", "CancelUnconfirmedEmail",new {email=Model.ConfirmedEmail})
        }
        else
        {
            @Html.ActionLink("Change Email", "ChangeEmail")
        }
    </dd>

4. Add those new modifications

First, let's add ChangeEmail:

View Model:

public class ChangeEmailViewModel
{
    public string ConfirmedEmail { get; set; } 
    [Required]
    [EmailAddress]
    [Display(Name = "Email")]
    [DataType(DataType.EmailAddress)]
    public string UnConfirmedEmail { get; set; } 
}

Get Action:

 public ActionResult ChangeEmail()
    {
        var user = UserManager.FindById(User.Identity.GetUserId());
        var model = new ChangeEmailViewModel()
        {
            ConfirmedEmail = user.Email
        };

        return View(model);
    }

View:

@model ProjectName.Models.ChangeEmailViewModel
@{
ViewBag.Title = "Change Email";
}

<h2>@ViewBag.Title.</h2>

@using (Html.BeginForm("ChangeEmail", "Account", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
    @Html.AntiForgeryToken()
    <h4>New Email Address:</h4>
    <hr />
    @Html.ValidationSummary("", new { @class = "text-danger" })
    @Html.HiddenFor(m=>m.ConfirmedEmail)
    <div class="form-group">
        @Html.LabelFor(m => m.UnConfirmedEmail, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.UnConfirmedEmail, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Email Link" />
        </div>
    </div>
}

HttpPost Action:

    [HttpPost]
    public async Task<ActionResult> ChangeEmail(ChangeEmailViewModel model)
    {
        if (!ModelState.IsValid)
        {
            return RedirectToAction("ChangeEmail", "Manage");
        }

        var user = await UserManager.FindByEmailAsync(model.ConfirmedEmail);
        var userId = user.Id;
        if (user != null)
        {
            //doing a quick swap so we can send the appropriate confirmation email
            user.UnConfirmedEmail = user.Email;
            user.Email = model.UnConfirmedEmail;
            user.EmailConfirmed = false;
            var result = await UserManager.UpdateAsync(user);

            if (result.Succeeded)
            {

                string callbackUrl =
                await SendEmailConfirmationTokenAsync(userId, "Confirm your new email");

                var tempUnconfirmed = user.Email;
                user.Email = user.UnConfirmedEmail;
                user.UnConfirmedEmail = tempUnconfirmed;
                result = await UserManager.UpdateAsync(user);

                callbackUrl = await SendEmailConfirmationWarningAsync(userId, "You email has been updated to: "+user.UnConfirmedEmail);


            }
        }
        return RedirectToAction("Index","Manage");
    }

Now add that warning:

    private async Task<string> SendEmailConfirmationWarningAsync(string userID, string subject)
    {
        string code = await UserManager.GenerateEmailConfirmationTokenAsync(userID);
        var callbackUrl = Url.Action("ConfirmEmail", "Account",
           new { userId = userID, code = code }, protocol: Request.Url.Scheme);
        await UserManager.SendEmailAsync(userID, subject,
           "Please confirm your account by clicking <a href=\"" + callbackUrl + "\">here</a>");

        return callbackUrl;
    }

And now finally, we can put in the cancellation of the new email address:

    public async Task<ActionResult> CancelUnconfirmedEmail(string emailOrUserId)
    {
        var user = await UserManager.FindByEmailAsync(emailOrUserId);
        if (user == null)
        {
            user = await UserManager.FindByIdAsync(emailOrUserId);
            if (user != null)
            {
                user.UnConfirmedEmail = "";
                user.EmailConfirmed = true;
                var result = await UserManager.UpdateAsync(user);
            }
        }
        else
        {
            user.UnConfirmedEmail = "";
            user.EmailConfirmed = true;
            var result = await UserManager.UpdateAsync(user);
        }
        return RedirectToAction("Index", "Manage");

    }

5. Update ConfirmEmail (the very very last step)

After all this back and forth we can now confirm the new email, which means we should remove the old email at the same time.

 var result = UserManager.ConfirmEmail(userId, code);
 if (result.Succeeded)
 {

     var user = UserManager.FindById(userId);
     if (!string.IsNullOrWhiteSpace(user.UnConfirmedEmail))
     {
         user.Email = user.UnConfirmedEmail;
         user.UserName = user.UnConfirmedEmail;
         user.UnConfirmedEmail = "";

         UserManager.Update(user);
     }
 }
Jonathan Sayce
  • 9,359
  • 5
  • 37
  • 51
Chris_
  • 716
  • 8
  • 11
  • 4
    we could add a claim rather than adding an additional field – galdin Dec 22 '15 at 04:12
  • 2
    This is a great complete answer and should really be the accepted one. Thanks for posting this it was very helpful. – Richard McKenna Jan 28 '16 at 11:09
  • 1
    Thanks @RichardMcKenna, I'm glad you found it helpful. I'm always conflicted between trying to keep it short...but wanting to give as much detail as someone may want. – Chris_ Jan 28 '16 at 18:32
  • good point @gldraphael, I still haven't mastered claims though...so this is my way for now at least. – Chris_ Jan 28 '16 at 18:35
  • Thanks for explaining and showing this so closely! Especially the second part. I really like this solution a lot more than the accepted one. – maracuja-juice Aug 17 '16 at 08:36
  • @MarioM: The code works essentially the same with WebForms. This was monumentally helpful to me. I was about 75% of the way, and this answer helped me to the finish line. – Boyd P Nov 17 '17 at 22:04
  • The link to the full example is down. – imqqmi Sep 18 '18 at 08:44
  • 1
    Why store the new email? If somebody knows how to login, and he wants to change the email to something else, generate the code, send to the known email address. Create a email change page, let the user fill in the new email address as confirmation. – André Oct 11 '18 at 17:12
4

Haven't looked at ChangeEmailOnIdentity2.0ASPNET yet, but couldn't you just take advantage of the fact that the UserName and Email values typically match? This allows you to change the Email column upon request and then UserName upon confirmation.

These two controllers seem to work for me:

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<ActionResult> ChangeUserName(LoginViewModel model)
    {
        IdentityResult result = new IdentityResult();
        try
        {
            if (ModelState.IsValid)
            {
                var user = await UserManager.FindByIdAsync(User.Identity.GetUserId());

                SignInStatus verify = await SignInManager.PasswordSignInAsync(user.UserName, model.Password, false, false);

                if (verify != SignInStatus.Success)
                {
                    ModelState.AddModelError("Password", "Incorrect password.");
                }
                else
                {
                    if (model.Email != user.Email)
                    {
                        user.Email = model.Email;
                        user.EmailConfirmed = false;

                        // Persist the changes
                        result = await UserManager.UpdateAsync(user);

                        if (result.Succeeded)
                        {
                            string code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);
                            var callbackUrl = Url.Action("ConfirmEmail", "Account", new { userId = user.Id, code }, protocol: Request.Url.Scheme);
                            await UserManager.SendEmailAsync(user.Id, "Confirm your updated email", "Please confirm your email address by clicking <a href=\"" + callbackUrl + "\">this</a>");

                            return RedirectToAction("Index", new { Message = ManageMessageId.ChangeUserNamePending });
                        }
                    }
                    else
                    {
                        ModelState.AddModelError("Email", "Address specified matches current setting.");
                    }
                }
            }
        }
        catch (Exception ex)
        {
            result.Errors.Append(ex.Message);
        }
        AddErrors(result);
        return View(model);
    }

    [AllowAnonymous]
    public async Task<ActionResult> ConfirmEmail(string userId, string code)
    {
        if (userId == null || code == null)
        {
            return View("Error");
        }
        var result = await UserManager.ConfirmEmailAsync(userId, code);

        if (result.Succeeded)
        {
            var user = await UserManager.FindByIdAsync(userId);
            if (user.Email != user.UserName)
            {
                // Set the message to the current values before changing
                String message = $"Your email user name has been changed from {user.UserName} to {user.Email} now.";

                user.UserName = user.Email;
                result = await UserManager.UpdateAsync(user);
                if (result.Succeeded)
                {
                    ViewBag.Message = message;

                    AuthenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
                }
                else
                {
                    result.Errors.Append("Could not modify your user name.");
                    AddErrors(result);

                    return View("Error");
                }
            }
            return View("ConfirmEmail");
        }
        else
        {
            return View("Error");
        }
    }
Doug Dekker
  • 353
  • 2
  • 9
3

In case anyone is looking for a solution with Asp.Net Core: Here things are much more simple, see this post on SO AspNet Core Generate and Change Email Address

axuno
  • 581
  • 4
  • 15
  • how does this answer address the issue posed in this SO post where user can enter invalid email address initially, and be left in limbo? – O.MeeKoh Jun 09 '21 at 19:54
0

I followed the steps of Jonathan to a brand new ASP.NET project to test the changes and worked like a charm. This is the link to the repository

Santiago Rebella
  • 178
  • 3
  • 12