Doing away with passwords in ASP.NET
Maybe alternatives should have more traction, and users less headaches.
When I first heard about Medium, long before I actually joined, I remember being blown away by its login system. I watched my boss at the time log in to his account by checking his e-mail, and using that link, and I asked “Does it not require any password?”
Years later, after doing a lot of reading on how to secure websites, applications and devices, I came to appreciate the many advantages that this approach offers to security.
Rationale
One of the most difficult security tasks for anyone developing an application, a website, or otherwise any other piece of software that deals with authenticating a user is reliably and, especially, securely authenticating that user.
Handling users’ passwords is an operation that exposes said password to numerous possible vectors of attack:
- A user has to type the password, making it vulnerable to keyloggers
- The client app (be it the browser, or a maybe a mobile app) needs to send the password to the server, so any number of man-in-the-middle- ort evil-twin-like techniques will work, especially if the user isn’t particularly security-savvy
- The server receives and needs to validate passwords, so, at least in one instance, some form of password is stored somewhere in the server memory, making it vulnerable to more advanced vulnerabilities
- The password needs to be either stored, or have a hash stored, so that it can be validated at a later time, thus making it vulnerable to attackers if this operation is not done properly
It should be obvious by now that the surface of attack for passwords is huge, and, although, in recent times, it has become harder and harder to successfully attack using either of the aforementioned vectors, we are still left with a giant elephant in the room:
Users.
It doesn’t matter how secure your password handling mechanism is if the user chooses “password” as his password. It makes no difference that you have a password policy that enforces really strong passwords, if the hapless employees of your client write their passwords on sticky notes, in plain sight. It matters not if you have multi-factor authentication, if your unsavvy users can’t distinguish between an actual login page and a phishing page.
Of course, this is nothing new. Major players in the field have had real trouble with keeping things secure for their users. And most such players have invested serious amounts of time and effort into securing things as much as humanly possible. Take the power and flexibility of an Active Directory domain, for example, and imagine, by looking a bit through its internal concepts and protocols, how many challenges it has had across the years. Or just take Facebook, with so many major data breaches that there are pages dedicated to analysing them. Even Apple, who has emphasised security as one of its top missions, was not immune.
So any developer of a new application should probably ask themselves: Am I knowledgeable ad experienced enough to figure this out? Can my solution really be on par with authentication solutions with a long and established history? And the answer is, in most cases, probably not.
Don’t get me wrong, software development and architecture skill is most likely not lacking in anyone brave enough to take on such a challenge. But one developer, or a small team, simply does not have the time or resources to implement (and especially maintain!) secure solutions in the same way as the major players in the field. And having an attitude like “Well, Facebook is huge, who’s going to come after lil’ ol’ Jackson O’Coder’s website?” doesn’t help a bit: keep in mind that Facebook has the resources to survive gigantic data breaches (as it has in the past multiple times), but only one small one may be enough to wipe your business off the face of the Earth. You might end up with nobody thinking that you’re worth their time, but, usually, that usually also means that your software also doesn’t bring in many customers.
Why do away with passwords, after all?
So, after reading the rationale, you’re still not yet convinced that, should a better option exist than to use passwords, you should definitely take it, let’s go through a set of advantages of doing away with passwords:
- Not using passwords will not expose them in case of a breach. This is a significant advantage for the user, since most people reuse their passwords on multiple applications, so, chances are good for an attacker that gains access to one password, that they will have gained access to most applications that person is using
- Not using passwords seriously lowers the amount of expended resources, since there is no need for extra encryption and/or hashing, as well as no need to write code for that, or to allot storage space
- Not using passwords does not hinder the ability of any framework to correctly authenticate a user in the slightest, nor does it prevent that user from maintaining a session
- Not using passwords seriously reduces liability in case of data breaches, and also seriously limits the usefulness of data collected by attackers. This is the argument that will most probably get the legal and financial department of any business up from their seats cheering, especially on applications that do not collect much data from their users
Now, further advantages come from the specific way in which you may choose to do away with passwords. As an ASP.NET developer, like myself, there are ready-made solutions and documentation for external login providers, like Facebook, Google, Twitter, Microsoft, Azure AD, Windows Authentication or other generic providers, and even multi-factor authentication and TOTP schemes. I won’t go into details here, but suffice to say that delegating authentication to a third party with seriously more horsepower than your team of developers has its advantages, in development efficiency, security and also user engagement.
However, this article is not about delegating to a third party. There is heap of documentation for that already. But, as advantageous as they are, they have one serious drawback: they are third parties, and, therefore, make your application enter the realm of regulations on data protection, tracking and privacy. And this would be totally fine for your app, so, by all means, go for this approach if it suits your purpose. But if it does not, a separate approach may be needed.
Enter personal effects
It is safe to assume that, if someone uses your software, they have access to at least one email address, and probably one phone number. And it is almost guaranteed that they will be using a smartphone of some sort.
One of the greatest security improvements in latest years was the incorporation of the smartphone in everyday life. It made things more secure by many a number of things, but the most important security improvement was actually not a security improvement at all: the ability to have email at everyone’s fingertips.
As such, gone were the days in which one would hook up laptop, connect it to a password-less, public Wi-Fi hotspot, and navigate to a more-or-less (usually less) secure web page to check their fancy domain e-mail like hotshot69@smartypants-email.com. Most users have since navigated away to a few standardised providers, which could, then, properly start treating the network between a user’s device and their servers like the insecure and most likely hostile environment that it actually is. By focusing security on a few sets of providers, both those providers and the smartphone makers have managed to make even email secure enough.
And, on top of that, there was the matter of the phone network, with every device guaranteed to have a tightly-regulated communication channel that is seriously difficult to attack in a practical way without serious investment of money and time.
And, on top of all, there’s the matter of the ongoing competition to make devices as secure as they can possibly be. Sandboxing, locking mechanisms, internal security, encryption, find-my-device techniques, remote nuking options, etc., all of these work in concert to make that one handheld pocket-stored device everyone has the ideal multi-factor-in-one authentication device.
So, let’s do it
Now that I’ve explained how we’re approaching, I will make a very short code introduction into how to properly do this in ASP.NET. For this purpose, I will assume that we’re using .NET 6, and that you already have some knowledge on how to properly handle a basic ASP.NET application.
While I will be using e-mail to try to authenticate the user, anything else you might wish to use (such as SMS, TOTP or push notifications) should work using the same principles.
First steps
We first need to set up services for identity and authorization in our DI container:
// Authentication and authorization
services.AddIdentity<IdentityUser<int>, IdentityRole<int>>()
.AddEntityFrameworkStores<MyDbContext>()
.AddDefaultTokenProviders();
services.AddAuthorization(
options =>
{
options.AddPolicy("AdministratorsOnly",
policyBuilder =>
{
policyBuilder.RequireRole("Administrators");
});
});
Please note that I am using standard IdentityUser and IdentityRole entities with integer primary keys, in an Entity Framework data store with default token providers. Feel free to adapt this to your own situation. In my case, the MyDbContext class was simply defined as:
public class MyDbContext : IdentityDbContext<IdentityUser<int>, IdentityRole<int>, int>
I am also adding an “AdministratorsOnly” policy as an example policy (checks whether the user is part of the “Administrators” group), which, of course, you can adapt to your own needs.
Next is to add the required middleware:
app.UseAuthentication();
app.UseAuthorization();
As per middleware indications, in case routing and endpoints are used, these need to be added between the UseRouting and the UseEndpoints or Map… methods (MapRazorPages, MapControllers, etc.).
And that’s all the plumbing, we’re ready to go implementing.
Simple login page
We need but a simple form with exactly one field to have a login page:
<h1>Log in</h1>
<form method="post" class="container-fluid">
<div class="row">
<div class="col-sm-12 col-md-4 col-lg-3 col-xl-2">
<label for="email" class="form-label">Your e-mail address:</label>
</div>
<div class="col-sm-12 col-md-8 col-lg-9 col-xl-10">
<input type="text" name="email" id="email" placeholder="my.name@email.com" class="form-control" />
</div>
<div class="row">
<div class="col-12 align-content-end">
<input type="submit" class="btn btn-primary" value="Log in"/>
</div>
</div>
</div>
</form>
Mind you, I am using bootstrap classes with responsive layout, which is what a template ASP.NET application uses. Obviously, feel free to adapt this to your own needs.
The form posts one field exactly, the email address to log in. I am using a Razor page to host this, and a model behind the scenes to capture the email value:
public class LoginModel : PageModel
{
private readonly LoginValidationService loginService;
public LoginModel(LoginValidationService loginService)
{
Requires.NotNull(out this.loginService, loginService);
}
public void OnGet()
{
}
public async Task<IActionResult> OnPost(
PostModel postModel,
CancellationToken cancellationToken = default)
{
if (!this.ModelState.IsValid)
{
return this.Page();
}
await this.loginService.CommitLogin(postModel.Email, cancellationToken);
return this.RedirectToPage("LoginMessageSent");
}
public class PostModel
{
[BindProperty(Name = "email")]
[MaxLength(255)]
[Required]
public string Email { get; set; }
}
}
Let’s dissect this a bit.
First off, we have a LoginValidationService instance (we will be creating this one later on, so let’s just take it as it is for now), which is injected and validated through the constructor.
Note: Requires.NotNull is a contracts-like validation and initialization method part of IX.StandardExtensions NuGet package.
We have no reason to do anything special for the GET method, as the magic happens when the form on the page posts back via the POST method.
When handling the POST method, we get a model containing only the email field (as defined in the PostModel class) and, if the model is valid, invokes the CommitLogin method with the email address, then redirects the user to some page that would tell him to check his email.
At this stage, we are only interested in getting the user’s email so that we can generate some secret code and a link to send to the user. This is only the first step, and the user’s visit to said link will trigger the second step of the login process.
Login and validation controller
Simply for the sake of diversity in my example, I chose to implement the login validation link handler as a view-less MVC controller. A Razor page will work just as well, so feel free to adapt as you see fit, but I just felt like it was slightly more work.
My code looks like this:
[Route("Login")]
public class LoginController : Controller
{
private readonly LoginValidationService lvs;
public LoginController(LoginValidationService lvs)
{
Requires.NotNull(out this.lvs, lvs);
}
[HttpGet("Validate")]
public async Task<IActionResult> ValidateLogin([FromQuery] string email, [FromQuery] string code)
{
var result = await this.lvs.ValidateLogin(
email,
code);
if (result)
{
return this.Redirect("~/");
}
return this.RedirectToPage("/Forms/Authentication/LoginFailed");
}
}
We have the same LoginValidationService injected, and we have the ValidateLogin action method that will invoke its ValodateLogin method with what is received as query string parameters by the user: the code, as well as the email address. We also handle the GET method, as no self-respecting email client will do a proper and secure POST for you without first alerting the user, and we don’t want unnecessary complications.
After receiving the result of the validation (which, in this case, is a Boolean value), the controller redirects the user to either a page telling him the login failed, or to the home page. Feel free to adapt to your own needs.
That’s all. Now, let’s move on to the LoginValidationService class.
Login validation service
Now the real magic happens. But, before it can, we need to define a class with quite a lot of fields:
public class LoginValidationService
{
private const string LoginValidationKey = "LOGINVALIDATION:{0}";
private readonly IConfiguration config;
private readonly IWebHostEnvironment host;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IAppCache cache;
private readonly UserManager<IdentityUser<int>> userManager;
private readonly SignInManager<IdentityUser<int>> signInManager;
private readonly ILogger<LoginValidationService> logger;
public LoginValidationService(
IConfiguration config,
IWebHostEnvironment host,
IHttpContextAccessor httpContextAccessor,
IAppCache cache,
UserManager<IdentityUser<int>> userManager,
SignInManager<IdentityUser<int>> signInManager,
ILogger<LoginValidationService> logger)
{
Requires.NotNull(out this.config, config);
Requires.NotNull(out this.host, host);
Requires.NotNull(out this.httpContextAccessor, httpContextAccessor);
Requires.NotNull(out this.cache, cache);
Requires.NotNull(out this.userManager, userManager);
Requires.NotNull(out this.signInManager, signInManager);
Requires.NotNull(out this.logger, logger);
}[... rest of class ...]
}
Let’s take all of them in turn.
We will probably need some sort of configuration access, so we inject the IConfiguration interface as the easiest way of accessing it. More documentation on ASP.NET configuration over at Microsoft Docs.
We want to have some information on the current host, as chances are that you may not want to send emails (or that many emails) from a developer machine. You will see how that happens later.
We will need access to the HTTP context, and we get access to that via injecting the IHttpContextAccessor. More documentation on injecting and using ASP.NET HttpContext over at Microsoft Docs.
We then do a rather interesting trick. How exactly this is performed depends on what your actual needs are, so you may need to adapt this part to suit your business needs. But for a very simple application, this will work:
We need to generate a code that will be available to the user for 10 or so minutes, and disappear afterwards. We don’t necessarily need to store it, just offer the application a simple way to access it, while it’s not yet expired. And we don’t expect a plethora of such codes to need to be valid at the same time. This sounds exactly like how a cached object behaves, so, naturally, a cache (in our particular cache an in-memory cache) will do the trick. I am using LazyCache, and inject IAppCache.
An important thing to note now is the constant LoginValidationKey. It will be used in order to work with our IAppCache instance.
We then inject the UserManager, and please note that it has the user class mentioned above as a type parameter. We will be using this to check whether the user has logged in before, and to create a database entry for it if not.
Afterwards, we will use SignInManager to instruct ASP.NET to actually do the login, and, finally, the ILogger to give us the opportunity to log things if needed.
So let’s see how this works.
First stage login
Let’s analyse the code for the first stage of the login:
public async ValueTask CommitLogin(string emailAddress, CancellationToken cancellationToken = default)
{
// Create login key
string key = string.Format(
LoginValidationKey,
Requires.NotNullOrWhiteSpace(emailAddress));
// Validate that there hasn't already been a login
if (this.cache.TryGetValue<string>(
key,
out _))
{
return;
}
// Generate code
string loginCode = DataGenerator.RandomAlphanumericString(50);
// Save code to cache
this.cache.Add(key, loginCode, new DateTimeOffset(DateTime.Now.AddMinutes(10)));
// Send e-mail message
if (this.host.IsDevelopment())
{
this.logger.LogDebug("Message sending on login was triggered, but will not run on a development environment.");
return;
}
try
{
[... initialize email sending service here ...] var httpContext = this.httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No HTTP context, cannot proceed.");
var linkGenerator = httpContext.RequestServices.GetService<LinkGenerator>() ?? throw new InvalidOperationException("No link generator, cannot proceed.");
var website = linkGenerator.GetUriByName(httpContext, "default", new RouteValueDictionary());
var loginValidateAddress = linkGenerator.GetUriByAction(
httpContext,
"ValidateLogin",
"Login");
var loginPageAddress = linkGenerator.GetUriByPage(httpContext, "/Forms/Authentication/Login");
var subject = $"Login request for {emailAddress}";
var plainTextContent = string.Format(
@"Thank you for your interest in the {2} site.
Please login using the following link (valid for 10 minutes):
{3}?code={0}&email={1}
This link should validate your session, and should redirect you towards the home page.
If you cannot use this link, this means it might have expired, so please try to log in again.",
loginCode,
emailAddress,
website,
loginValidateAddress);
var htmlContent = string.Format(
@"<h1>Login</h1>
<p>Thank you for your interest in the <a href=""{2}"">{2}</a> site.<p>
<p>Please login using the following link (valid for <strong>10 minutes</strong>):</p>
<p><a href=""{3}?code={0}&email={1}"">{3}?code={0}&email={1}</a><p>
<p>...or copy and paste it into a browser's navigation bar.</p>
<p>This link should validate your session, and should redirect you towards the home page.</p>
<p>If you cannot use this link, this means it might have expired, so please try to <a href=""{4}"">log in again</a>.</p>",
loginCode,
emailAddress,
website,
loginValidateAddress,
loginPageAddress);
[... send the email with the content ...]
}
catch (Exception e)
{
this.logger.LogError(e, "Could not send the login email due to an exception.");
return;
}
}
Long, but simple, isn’t it?
First, create a proper cache key from the email address, check if one is already valid, if not then create the code (note: DataGenerator.RandomAlphanumericString is part of the IX.StandardExtensions package). We’ll save the code in the cache using the already-existing key. We check whether or not we are on a development environment (such as a developer machine, in order to not go through with email, in order to avoid spamming the SMTP server), and, if we aren’t, we can proceed to create some links.
Please note how we’re using the HTTP context. We are getting a LinkGenerator instance from it, and we’re using that to generate links. Why do we need to tie this to a HttpContext?
The answer lies in how ASP.NET (and web application frameworks in general) can generate links. If, for instance, I am accessing the application using www.myawesomeapp.com, then that’s what we need to make links relative to. If, however, we are using a staging server, such as staging.myawesomeapp.com, then we need to make the links relative to that, otherwise they won’t work. We also need to think: are we using HTTP or HTTPS (although this will become an irrelevant question pretty soon)? and so on. We could just hardcode some addresses in the configuration file(s), but that implies maintaining those links, and that is simply a useless endeavour since we have this working solution.
Next up is the actual email sending, with the content (links included) showcased in the code sample.
Second stage login
Code for the second stage is slightly more complex:
public async ValueTask<bool> ValidateLogin(
string emailAddress,
string code,
CancellationToken cancellationToken = default)
{
// Create login key and code
string key = string.Format(
LoginValidationKey,
Requires.NotNullOrWhiteSpace(emailAddress));
var innerCode = Requires.NotNullOrWhiteSpace(code);
// Validate that there was a login that hasn't expired
if (!this.cache.TryGetValue<string>(
key,
out var existingCode))
{
return false;
}
// Compare code
if (!existingCode.OrdinalEquals(innerCode))
{
return false;
}
// Do login
try
{
var existingUser = await this.userManager.FindByNameAsync(emailAddress);
if (existingUser == null)
{
// If there is no current user, let's create one
existingUser = new IdentityUser<int>(emailAddress);
var result = await this.userManager.CreateAsync(existingUser);
if (!result.Succeeded)
{
return false;
}
// Send an e-mail to the new user, if we're not in development mode
if (!this.host.IsDevelopment())
{
[... send user an email with user creation ...]
}
}
await this.signInManager.SignInAsync(
existingUser,
false);
// Done
return true;
}
catch (Exception e)
{
this.logger.LogError(e, "Could not perform login due to an exception.");
return false;
}
}
The major difference is that we actually want our cache to contain an entry now, which we dutifully verify.
There’s only one thing to mention at this point: The default Identity implementation does expect that a user is persisted in order to properly work, so that’s what the userManager does — tries to find an existing entry and, if none is found, tries to create one. Only thing it needs to set is the user name, which is the email address.
And last, but not least, signInManager then calls SignInAsync and instructs ASP.NET to log in the user. From that point onwards, there is a user session created, and you can use the identity to do all sort of session-based stuff:
@if (User.Identity?.IsAuthenticated ?? false)
{
<a class="nav-link text-dark">Welcome, @User.Identity.Name! @Html.GravatarImage(User.Identity.Name, size: 25)</a>
}
else
{
<a class="nav-link text-dark" asp-page="/Forms/Authentication/Login">Log in</a>
}
(Note: the Html.GravatarImage can be used from Andrew Freemantle’s GitHub repository and does pretty much what you’d expect)
I won’t go into details about the logout page, as the only thing it needs to do is to call SignInManager.SignOutAsync method.
Conclusion
As Medium clearly demonstrates itself, doing away with passwords and not bothering with third-party social media is possible.
I hope that my rather short implementation example is conclusive proof that it is also accessible to developers.
Remember: everyone is responsible for security, and your first duty as a developer of secure applications is to give attackers as little surface as possible to attack, and as few chances as possible to succeed. Some things will always be out of your hands, but, where they’re not, it is your responsibility to make sure that security is close to impossible to compromise.