Search code examples
.net-coreasp.net-identityasp.net-core-3.0

Core3/React confirmation email not sent


This question applies to a core3/react project with an external identity provider, created as follows.

dotnet new react --auth Individual --use-local-db --output conf

and modified to support an external identity provider. The package is added

dotnet add package Microsoft.AspNetCore.Authentication.MicrosoftAccount

and startup is modified

services.AddAuthentication()
.AddMicrosoftAccount(options =>
{
    options.ClientId = Configuration["Authentication:Microsoft:ClientId"];
    options.ClientSecret = Configuration["Authentication:Microsoft:ClientSecret"];
    options.CallbackPath = "/signin-microsoft";
})

After following the instructions provided by Microsoft I tested my work by registering as a user. No errors were thrown but the promised confirmation email never arrived.

Following the troubleshooting advice at the end of the instructions I set a breakpoint at the start of the SendEmailAsync method of my implementation of IEmailSender and repeated the exercise. The breakpoint is not hit.

If I manually confirm the account by updating the database,

  • I am able to log in.
  • The Forgot Password link takes me to a password recovery page and using this hits my breakpoint and successfully sends a password reset email with a link that works.

Clearly my implementation of IEmailSender works and is correctly registered. It's not exactly the same as the sample code because I have my own Exchange server and didn't use SendGrid, but it sent an email successfully for password reset and I can repeat this any number of times without a hitch.

Against the slim possibility that it is somehow the cause of the problem, here's my implementation

public class SmtpEmailSender : IEmailSender
{
    public SmtpEmailSender(IOptions<SmtpOptions> options)
    {
        this.smtpOptions = options.Value;
    }
    private SmtpOptions smtpOptions { get; }
    public Task SendEmailAsync(string email, string subject, string htmlMessage)
    {
        var smtp = new SmtpClient();
        if (!smtpOptions.ValidateCertificate)
        {
            smtp.ServerCertificateValidationCallback = (s, c, h, e) => true;
        }
        smtp.Connect(smtpOptions.Host, smtpOptions.Port, SecureSocketOptions.Auto);
        if (smtpOptions.Authenticate)
        {
            smtp.Authenticate(smtpOptions.Username, smtpOptions.Password);
        }
        var message = new MimeMessage()
        {
            Subject = subject,
            Body = new BodyBuilder() { HtmlBody = htmlMessage }.ToMessageBody()
        };
        message.From.Add(new MailboxAddress(smtpOptions.Sender));
        message.To.Add(new MailboxAddress(email));
        return smtp.SendAsync(FormatOptions.Default, message).ContinueWith(antecedent =>
        {
            smtp.Disconnect(true);
            smtp.Dispose();
        });
    }
}

Registration in startup.cs looks like this.

            services.AddTransient<IEmailSender, SmtpEmailSender>();
            services.Configure<SmtpOptions>(Configuration.GetSection("SmtpOptions"));

SmptOptions is just settings hauled out of appsettings.json and injected into the ctor. Obviously that aspect works or password reset emails wouldn't work.

There can't be anything wrong with the registration because the app stops producing a message about needing to read and follow the account confirmation instructions I linked.

To see whether the problem was caused by some inadvertent side-effect of my code I created an instrumented stub of IEmailSender

public class DummyEmailSender : IEmailSender
{
    private readonly ILogger logger;

    public DummyEmailSender(ILogger<DummyEmailSender> logger)
    {
        this.logger = logger;
    }
    public Task SendEmailAsync(string email, string subject, string htmlMessage)
    {
        logger.LogInformation($"SEND EMAIL\r\nemail={email} \r\nsubject={subject}\r\nhtmlMessage={htmlMessage}\r\n{new StackTrace().ToString().Substring(0,500)}");
        return Task.CompletedTask;
    }
}

I also updated service registration to match.

This is the simplest possible instrumented stub, and the observed behaviour is the same, it's invoked when the Forgot Password form is submitted and is not invoked when the Confirm Registration form is submitted.

Has anyone ever got the horrible thing to work? How?


Immediately before the failure, this URL https://wone.pdconsec.net/Identity/Account/ExternalLogin?returnUrl=%2Fauthentication%2Flogin&handler=Callback looks like this

enter image description here

Inspecting the page we find the Register button posts a form to /Identity/Account/ExternalLogin?returnUrl=%2Fauthentication%2Flogin&amp;handler=Confirmation

The code for this is available from the dotnet repository. After cloning the repo https://github.com/dotnet/aspnetcore.git I read the build instructions and succeeded in building dotnet 5 preview. Then I ran clean before switching to the tagged branch release/3.1 to build debugging packages for core3.1 but this fails because the tagged branch brings into play a version of msbuild that's just slightly too old and the remedy suggested by the error message doesn't seem to work. Since my grip on PowerShell is weak (the build script is PowerShell) I am reduced to code inspection. The pertinent code looks like this.

public override async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                        "/Account/ConfirmEmail",
                        pageHandler: null,
                        values: new { area = "Identity", userId = userId, code = code },
                        protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, isPersistent: false);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

It looks like it ought to work. What do we know?

  • No unhandled errors are thrown, it makes it through to RegisterConfirmation which puts up a message about the email that never comes.
  • CreateUser is invoked and succeeds. We know this because the user is created in the database. So it definitely gets past there, which implies that ModelState isn't null and .IsValid is true.
  • IEmailSender.SendEmailAsync is not actually invoked, despite the code above.
  • If result.Succeeded is true there should be a log message saying something like "User created an account using Microsoft Account provider"
  • It redirects to https://localhost:5001/Identity/Account/[email protected]

I'm seeing log messages for most things. Trying to register a second time after the first pass creates the user but fails to send the email, a warning about a DuplicateUserName appears on the console and in the event log. Setting the confirmation directly in the database we are able to log in and then interactively delete the account, and logs appear for these activities.

But no logs appear for confirmation. What's really doing my head in is the fact that it then redirects to https://localhost:5001/Identity/Account/[email protected]

That's crazy. In order to get to there, userManager.AddLoginAsync() must return true and the very next line in that case is a write to the logger about creating the user account.

This makes no sense.


Solution

  • I created a whole new project and worked the exercise. It works perfectly.

    What's the difference? The failing version was added to an existing project that has been jerked back and forth between 3.0 and 3.1 several times in the course of troubleshooting CICD issues. Clearly it's damaged in some unobvious way and this is a non-issue.

    The only reason I haven't deleted the whole question is others may fall down this hole.