I’m developing a SaaS based product, and need Authentication (who you are) and Authorisation (what you’re allowed to do) in my app.

Update 21st Oct 2020

Please consider reading my follow up article on Cookie Authentication where I talk about why I don’t use the techniques mentioned here.

Update 25th Aug 2020

This below article was written in early 2020 and remained non-published. The reason for this was that for my use case it felt overly complex.

I followed this article up with a 3rd Party External Authentication article

Andrew Lock has a great 2nd June 2020 tutorial on how to scaffold out, then only use the relevant bit. This is a very well written article covering some of what is here, and adding in a very neat way of not having to maintain all the scaffolded code.

Intro

Identity on ASP.NET Core gives us:

  • Local Login with details stored in my database
  • Manages users, passwords, profile data, roles, claims, tokens, email confirmation and more
  • External/Social Login eg Google, Facebook, Twitter, Microsoft Account

I use a password manager eg LastPass and never use external authentication providers such as Facebook, Google, Twitter, Microsoft as I can never remember which one I’ve used on a particular website. I enjoy the simplicity of separate passwords on each site which are stored in a password manager.

It seems though that I’m in the minority I suspect because I regularly use 3 different machines so its difficult to remember which provider I used on a particular website. Google has become a winner apparently.

MS Docs on ASP.NET Core Security are the obvious place to start.

Lets start with a single username and password login, then add in External afterwards.

I am not using a WebAPI or a SPA, otherwise I would be looking at something like IdentityServer4, Azure AD to secure the API’s

File new project VS GUI

I highly recommend doing this as adding scaffoled identity to an existing app is not straightforward and having a working example is invaluable.

Following along from this MS Doc

alt text

Or we can do it via the CLI

# MSSQL version -uld is use-local-database
dotnet new webapp --auth Individual -uld -o WebApp1

Once the project template has finished we need to create the database, and I’m using the default MSSQL and have modified the appsettings.json file to give a saner name for my db.

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-WebApplication2;Trusted_Connection=True;MultipleActiveResultSets=true"
  }...

MSSQL

Lets run the migrations, which means actually standing up the database and creating tables, views etc..

https://dotnet.microsoft.com/download/dotnet-core

# latest version of dotnet installed?
# currently v3.1.2 and SDK is 3.1.102 (see download link above for latest)
dotnet --info

# make sure you have the global ef tools installed
dotnet tool install -g dotnet-ef

# apply the migrations for LocalDB
dotnet ef database update

Could use the PowerShell package manager console in Visual Studio to do Update-Database but I am a fan of the command line

Could not execute because the specified command or file was not found

alt text
See this thread if you have any problems with the tool, for example forgetting to install it!

So this works well out of the box giving an EF Context backed store in MSSQL (localdb)\mssqllocaldb

alt text

Run Identity with defaults

alt text

The defaults are that we need 6 characters, alphanumeric, etc..

alt text

A nice developer feature is to have a friendly auto email confirm.

Scaffolding out Pages - create full identity UI source

alt text

Lets create full identity UI source

Where are the pages eg /Identity/Account/RegisterConfirmation? They are provided as a Razor Class Library in a NuGet Microsoft.AspnetCore.Identity.UI package.

This has always been perplexing for me that you have to do this separate step to see the source pages as I always end up modifying them.

Scaffold identity into a Razor project with authorization

# clean nuget packages (close Visual Studio)
# this helps get rid of any dependency issues which may be cached
dotnet nuget locals all --clear

# install the scaffolder globally
dotnet tool install -g dotnet-aspnet-codegenerator

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.UI
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools

# useful for app.UseDatabaseErrorPage();
dotnet add package Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore

# miss out the --files flag to get all identity UI pages --force to override all files
dotnet aspnet-codegenerator identity -dc WebApplication2.Data.ApplicationDbContext

So this added in these files:

# show all untracked files
# git status -u
Areas/Identity/IdentityHostingStartup.cs
Areas/Identity/Pages/Account/ # lots of files added in here
Areas/Identity/Pages/_ValidationScriptsPartial.cshtml
Areas/Identity/Pages/_ViewImports.cshtml
ScaffoldingReadMe.txt

C#8 Nullable Reference Types issue

I found an issue and raised an issue with a fix as I like to use Nullable Ref Type checking

Put Authorize on a page

Lets make it so the User has to be authenticated to view the privacy page.

[Authorize]
public class PrivacyModel : PageModel
{
    private readonly ILogger<PrivacyModel> _logger;

    public PrivacyModel(ILogger<PrivacyModel> logger)
    {
        _logger = logger;
    }

    public void OnGet()
    {
    }
}

alt text

It works!

Update Startup.cs

As I want to retain full control of identity lets Create Full Identity UI Source

AddDefaultIdentity

AddDefaultIdentity was introduced in .NET Core 2.1 to cut down code. However I like to be explicit:

//services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
//    .AddEntityFrameworkStores<ApplicationDbContext>();

// being more explicit
services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

// if not using default need this for redirect to login when access denied
services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = $"/Identity/Account/Login";
    options.LogoutPath = $"/Identity/Account/Logout";
    options.AccessDeniedPath = $"/Identity/Account/AccessDenied";
});

Where to keep Identity Options

The generated file is in

[assembly: HostingStartup(typeof(BLC.Website.Areas.Identity.IdentityHostingStartup))]
namespace BLC.Website.Areas.Identity
{
    public class IdentityHostingStartup : IHostingStartup
    {
        public void Configure(IWebHostBuilder builder)
        {
            builder.ConfigureServices((context, services) =>
            {
                // defaults copied from 
                // https://docs.microsoft.com/en-gb/aspnet/core/security/authentication/identity?view=aspnetcore-3.1&tabs=netcore-cli#configure-identity-services

                services.Configure<IdentityOptions>(options =>
                {
                    // Password settings.
                    options.Password.RequireDigit = false;
                    options.Password.RequireLowercase = false;
                    options.Password.RequireNonAlphanumeric = false;
                    options.Password.RequireUppercase = false;
                    options.Password.RequiredLength = 6;
                    options.Password.RequiredUniqueChars = 0;

                    // Lockout settings.
                    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                    options.Lockout.MaxFailedAccessAttempts = 5;
                    options.Lockout.AllowedForNewUsers = true;

                    // User settings.
                    options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
                    options.User.RequireUniqueEmail = true;

                    // not default, but came as part of the template
                    options.SignIn.RequireConfirmedAccount = false;
                    options.SignIn.RequireConfirmedEmail = false;
                });

                // setup the redirect to login page when going to an [Authorised] page
                services.ConfigureApplicationCookie(options =>
                {
                    // Cookie settings
                    options.Cookie.HttpOnly = true;
                    options.ExpireTimeSpan = TimeSpan.FromMinutes(5);

                    options.LoginPath = "/Identity/Account/Login";
                    options.AccessDeniedPath = "/Identity/Account/AccessDenied";
                    options.SlidingExpiration = true;
                });
            });
        }
    }
}

Workflow

Should we be waiting for RequiredConfirmedAccount or RequireConfirmedEmail?

This can be very annoying if emails are not delivered quickly, which is a huge topic.

alt text

This was 3 minutes after I’d sent the email to SendGrid’s API and it was still processing. I got it after 5 minutes. The recovery email seemed to be instant.

Testing

Chrome delete cookies on localhost to see what cookies are placed.

Lets get onto Release asap and the easiest way is to publish a WebApp to Azure.

alt text

I created a DB too with the connection string as DefaultConnection. It created a Standard 10DTU as default where there is a Basic 5DTU which is fine for testing. I changed it over in the portal.azure.com UI.

Default settings gave me (on 2nd Feb 2020):

HTTP Error 500.30 - ANCM In-Process Start Failure

Set deployment mode to Self Contained and it will work. This was due to Azure not having 3.1.1 version of the runtime. It had 3.1.0. This blog has a nice way of finding out

alt text

alt text

Add in Development settings in Azure so we can see the problem

alt text

There is an option in VS2019 Publish to include migration on publish. Lets do that.

Cookies

alt text

Account Confirmation and Password Recovery

I want to make sure email works properly for account confirmation and password recovery, and we need to set this up manually.

MS Docs on setting up email

I’m using SendGrid

Could store secrets securely in local but I’m not as am doing dev on 3 local machines and have to store the SendGrid API key somewhere.

dotnet add package SendGrid

As usual weird stuff happens while testing email!

So lets go back to smtp4dev the old windows GUI version which is a dummy service to make sure our app is actually working.

// Startup.cs
// using Smtp4Dev and SendGrid
services.AddTransient<IEmailSender, EmailSender>();

// Service/EmailSender.cs
public class EmailSender : IEmailSender
{
    private readonly IWebHostEnvironment env;
    public EmailSender(IWebHostEnvironment env) => this.env = env;

    public Task SendEmailAsync(string email, string subject, string message) => 
        env.IsDevelopment() ? ExecuteSmtp4Dev(subject, message, email) 
            : ExecuteSendGrid(subject, message, email);

    public Task ExecuteSmtp4Dev(string subject, string message, string email)
    {
        var host = "localhost";
        var port = 25;
        var enableSsl = false;
        var userName = "";
        var password = "";
        var senderEmail = "davemateer@gmail.com";

        var client = new SmtpClient(host, port)
        {
            Credentials = new NetworkCredential(userName, password),
            EnableSsl = enableSsl
        };
        return client.SendMailAsync(new MailMessage(senderEmail, email, subject, message) { IsBodyHtml = true });
    }

    public Task ExecuteSendGrid(string subject, string message, string email)
    {
        var sendGridKey = "SECRET";
        var client = new SendGridClient(sendGridKey);
        var sendGridUser = "davemateer@gmail.com";
        var msg = new SendGridMessage()
        {
            From = new EmailAddress("davemateer@gmail.com", sendGridUser),
            Subject = subject,
            PlainTextContent = message,
            HtmlContent = message
        };
        msg.AddTo(new EmailAddress(email));
        msg.SetClickTracking(false, false);
        return client.SendEmailAsync(msg);
    }

Then I had to update the RegisterConfirmation.cshtml.cs commenting out the developer code to register a sender:

alt text

Then even sending emails is tricky.. outlook.com is ignoring the emails I’m sending from my davemateer@gmail.com address (well taking ages to show up.. maybe 5 minutes) and going to junk. Obviously need to set this up properly.

Warnings in Log file

TL;DR - I’m ignorning these warnings as I don’t mind about expired tokens and having to login again on service restart

Here is my article on Setting up Serilog for SignalR and ASP.NET Core 3 From looking at these logs I found warnings on my production (Linux) server which didn’t appear on my dev (Windows) machine.

Here is a test WebApplication5 with infra.sh deployment script which spins up Kestrel, NGinx on Ubuntu

2020-03-06 15:49:10.158 +00:00 [WRN] Using an in-memory repository. Keys will not be persisted to storage.
2020-03-06 15:49:10.160 +00:00 [WRN] Neither user profile nor HKLM registry available. Using an ephemeral key repository. Protected data will be unavailable when application exits.
2020-03-06 15:49:10.185 +00:00 [WRN] No XML encryptor configured. Key "cd2047d5-7f97-43fe-9435-b3802cf5723d" may be persisted to storage in unencrypted form.
2020-03-06 15:50:08.083 +00:00 [WRN] Using an in-memory repository. Keys will not be persisted to storage.
2020-03-06 15:50:08.091 +00:00 [WRN] Neither user profile nor HKLM registry available. Using an ephemeral key repository. Protected data will be unavailable when application exits.
2020-03-06 15:50:08.188 +00:00 [WRN] No XML encryptor configured. Key "6389be03-53e2-4a9b-9905-078830712dd3" may be persisted to storage in unencrypted form.
2020-03-06 15:52:37.626 +00:00 [WRN] Failed to determine the https port for redirect.

From the docs it seems strange it doesn’t work on a single machine by default with the defaults

Others have solved but this is confusing why a single machine is throwing these warnings.

alt text

Console showing no errors

alt text

But log showing errors coming in before the app starts up

Conclusion

It takes some work but there is good authentication and authorisation out of the box with ASP.NET Core. Lets get Google auth working as should be more easier for the user..I hope