60

Question

How can I implement Basic Authentication with Custom Membership in an ASP.NET Core web application?

Notes

  • In MVC 5 I was using the instructions in this article which requires adding a module in the WebConfig.

  • I am still deploying my new MVC Coreapplication on IIS but this approach seems not working.

  • I also do not want to use the IIS built in support for Basic authentication, since it uses Windows credentials.

A-Sharabiani
  • 17,750
  • 17
  • 113
  • 128

5 Answers5

35

ASP.NET Security will not include Basic Authentication middleware due to its potential insecurity and performance problems.

If you require Basic Authentication middleware for testing purposes, then please look at https://github.com/blowdart/idunno.Authentication

blowdart
  • 55,577
  • 12
  • 114
  • 149
  • 2
    We have used the [Odachi](https://github.com/Kukkimonsuta/Odachi) library from [kukkimonsuta](https://github.com/Kukkimonsuta/) in a project at work, because of some compatibility "issues". It worked well for what we needed. –  Feb 09 '16 at 21:34
  • Both links are broken. This is what you want: https://github.com/Kukkimonsuta/Odachi/ – Muhammad Rehan Saeed Jun 16 '16 at 10:48
  • 7
    What do we do in situations like if we are trying to write a [Web Hook for Visual Studio Team Services](https://www.visualstudio.com/en-us/docs/marketplace/integrate/service-hooks/services/webhooks) which only supports basic authentication? – Scott Chamberlain May 23 '17 at 21:39
  • 8
    Why "for testing purposes" only? Why couldn't you use this approach in production? – NickG Sep 10 '18 at 09:36
  • 13
    @blowdart Care to elaborate on "potential insecurity and performance problems"? Basic Auth over https is perfectly safe and widely used in production (e.g. stripe, mailchimp, aws etc). "testing purposes" is just wrong guidance as well as insufficient context. Unless there is something poorly implemented in asp.net security itself that we're missing. – DeepSpace101 Jul 24 '19 at 20:57
  • @blowdart I've been trying to implement your library but keep getting 404 when I set up a simple controller returning content, I assume the [Authorize] parameter is still required using your package but I cannot get around this issue. Any advice pls? – Journeyman1234 Mar 17 '20 at 17:51
  • I can confirm @DeepSpace101 answer below. We implemented a custom Basic Authentication Filter in production and it worked. Now security concern is a whole new topic - this answer seems to imply Basic Authentication can't be implemented in production which is slightly misleading. – Mustapha Othman Mar 02 '21 at 20:44
  • The weakness of basic authentication over SSL lies in the fact that the password is sent with every request and it is always sent at the same location (within the request). If the password is short and if a hacker can tap into thousands of request they can crack the password using Rainbow Tables – Fadi Oct 04 '21 at 20:59
  • 2
    Agreed basic auth is terrible but it seems Azure DevOps service hooks do not support anything else. – span Oct 12 '21 at 09:48
  • @Fadi rainbow tables apply only to **hashed** passwords without salt. Basic auth sends base64 (plain text basically) passwords which are **encrypted** via SSL. https would be unsafe if you could bruteforce short encrypted strings. Basic auth combined with HTTP only cookies is as safe as OpenID connect if you don't have to store users credentials on the client side. In fact it's probably safer than most of the half assed JWT implementations for ASP.Net you find on the web – Cyril Jul 13 '22 at 11:17
  • @Cyril re basic auth + cookies. Is that the typical way people use basic authentication though? Also will browsers behave how you expect in that case? i.e. afaik browsers assume they need to resend the basic auth credentials on every request – eglasius Feb 15 '23 at 08:19
  • @eglasius not entirely sure what you are getting at. Typical way is strongly dependant on framework. In asp.net core I would use cookie + some custom post call that receives the credentials. Browsers automatically send cookies. It's not related to basic auth. What I wanted to point out is that the unsafe part of basic auth is saving plain text credentials on the client. Sending them over https is as safe as any other authentication mechanism. – Cyril Feb 15 '23 at 16:11
  • @Cyril what basic authentication is and how it is supported by browsers does not depend on the server framework. When a site uses basic authentication on the standard way, the browser itself prompts the user for the username and password and then certainly sends those credentials on every single request for that site afterwards. – eglasius Feb 15 '23 at 21:51
  • @eglasius that's only partially correct. The browser will only prompt you if the server sends the "www-authenticate" header. Additionally you can suppress the prompt from the frontend by sending the "X-Requested-With: XMLHttpRequest" header. And if you do that the browser won't send the credentials automatically. – Cyril Feb 16 '23 at 21:35
12

ASP.NET Core 2.0 introduced breaking changes to Authentication and Identity.

On 1.x auth providers were configured via Middleware (as the accepted answer's implementation). On 2.0 it's based on services.

Details on MS doc: https://learn.microsoft.com/en-us/aspnet/core/migration/1x-to-2x/identity-2x

I've written a Basic Authentication implementation for ASP.NET Core 2.0 and publish to NuGet: https://github.com/bruno-garcia/Bazinga.AspNetCore.Authentication.Basic

Bruno Garcia
  • 6,029
  • 3
  • 25
  • 38
12

I'm disappointed by the ASP.NET Core authentication middleware design. As a framework it should simplify and led to greater productivity which isn't the case here.

Anyway, a simple yet secure approach is based on the Authorization filters e.g. IAsyncAuthorizationFilter. Note that an authorization filter will be executed after the other middlewares, when MVC picks a certain controller action and moves to filter processing. But within filters, authorization filters are executed first (details).

I was just going to comment on Clays comment to Hector's answer but didn't like Hectors example throwing exceptions and not having any challenge mechanism, so here is a working example.

Keep in mind:

  1. Basic authentication without HTTPS in production is extremely bad. Make sure your HTTPS settings are hardened (e.g. disable all SSL and TLS < 1.2 etc.)
  2. Today, most usage of basic authentication is when exposing an API that's protected by an API key (see Stripe.NET, Mailchimp etc). Makes for curl friendly APIs that are as secure as the HTTPS settings on the server.

With that in mind, don't buy into any of the FUD around basic authentication. Skipping something as basic as basic authentication is high on opinion and low on substance. You can see the frustration around this design in the comments here.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace BasicAuthFilterDemo
{
    public class BasicAuthenticationFilterAttribute : Attribute, IAsyncAuthorizationFilter
    {
        public string Realm { get; set; }
        public const string AuthTypeName = "Basic ";
        private const string _authHeaderName = "Authorization";

        public BasicAuthenticationFilterAttribute(string realm = null)
        {
            Realm = realm;
        }

        public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
        {
            try
            {
                var request = context?.HttpContext?.Request;
                var authHeader = request.Headers.Keys.Contains(_authHeaderName) ? request.Headers[_authHeaderName].First() : null;
                string encodedAuth = (authHeader != null && authHeader.StartsWith(AuthTypeName)) ? authHeader.Substring(AuthTypeName.Length).Trim() : null;
                if (string.IsNullOrEmpty(encodedAuth))
                {
                    context.Result = new BasicAuthChallengeResult(Realm);
                    return;
                }

                var (username, password) = DecodeUserIdAndPassword(encodedAuth);

                // Authenticate credentials against database
                var db = (ApplicationDbContext)context.HttpContext.RequestServices.GetService(typeof(ApplicationDbContext));
                var userManager = (UserManager<User>)context.HttpContext.RequestServices.GetService(typeof(UserManager<User>));
                var founduser = await db.Users.Where(u => u.Email == username).FirstOrDefaultAsync();                
                if (!await userManager.CheckPasswordAsync(founduser, password))
                {
                    // writing to the Result property aborts rest of the pipeline
                    // see https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters?view=aspnetcore-3.0#cancellation-and-short-circuiting
                    context.Result = new StatusCodeOnlyResult(StatusCodes.Status401Unauthorized);
                }

                // Populate user: adjust claims as needed
                var claims = new[] { new Claim(ClaimTypes.Name, username, ClaimValueTypes.String, AuthTypeName) };
                var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthTypeName));
                context.HttpContext.User = principal;
            }
            catch
            {
                // log and reject
                context.Result = new StatusCodeOnlyResult(StatusCodes.Status401Unauthorized);
            }
        }

        private static (string userid, string password) DecodeUserIdAndPassword(string encodedAuth)
        {
            var userpass = Encoding.UTF8.GetString(Convert.FromBase64String(encodedAuth));
            var separator = userpass.IndexOf(':');
            if (separator == -1)
                return (null, null);

            return (userpass.Substring(0, separator), userpass.Substring(separator + 1));
        }
    }
}

And these are the supporting classes

    public class StatusCodeOnlyResult : ActionResult
    {
        protected int StatusCode;

        public StatusCodeOnlyResult(int statusCode)
        {
            StatusCode = statusCode;
        }

        public override Task ExecuteResultAsync(ActionContext context)
        {
            context.HttpContext.Response.StatusCode = StatusCode;
            return base.ExecuteResultAsync(context);
        }
    }

    public class BasicAuthChallengeResult : StatusCodeOnlyResult
    {
        private string _realm;

        public BasicAuthChallengeResult(string realm = "") : base(StatusCodes.Status401Unauthorized)
        {
            _realm = realm;
        }

        public override Task ExecuteResultAsync(ActionContext context)
        {
            context.HttpContext.Response.StatusCode = StatusCode;
            context.HttpContext.Response.Headers.Add("WWW-Authenticate", $"{BasicAuthenticationFilterAttribute.AuthTypeName} Realm=\"{_realm}\"");
            return base.ExecuteResultAsync(context);
        }
    }
Pang
  • 9,564
  • 146
  • 81
  • 122
DeepSpace101
  • 13,110
  • 9
  • 77
  • 127
7

Super-Simple Basic Authentication in .NET Core:

1. Add this utility method:

static System.Text.Encoding ISO_8859_1_ENCODING = System.Text.Encoding.GetEncoding("ISO-8859-1");
public static (string, string) GetUsernameAndPasswordFromAuthorizeHeader(string authorizeHeader)
{
    if (authorizeHeader == null || !authorizeHeader.Contains("Basic ")) 
        return (null, null);
    
    string encodedUsernamePassword = authorizeHeader.Substring("Basic ".Length).Trim();
    string usernamePassword = ISO_8859_1_ENCODING.GetString(Convert.FromBase64String(encodedUsernamePassword));

    string username = usernamePassword.Split(':')[0];
    string password = usernamePassword.Split(':')[1];

    return (username, password);
}

2. Update controller action to get username and password from Authorization header:

public async Task<IActionResult> Index([FromHeader]string Authorization)
{
    (string username, string password) = GetUsernameAndPasswordFromAuthorizeHeader(Authorization);

    // Now use username and password with whatever authentication process you want 

    return View();
}

Example

This example demonstrates using this with ASP.NET Core Identity.

public class HomeController : Controller
{
    private readonly UserManager<IdentityUser> _userManager;

    public HomeController(UserManager<IdentityUser> userManager)
    {
        _userManager = userManager;
    }

    [AllowAnonymous]
    public async Task<IActionResult> MyApiEndpoint([FromHeader]string Authorization)
    {
        (string username, string password) = GetUsernameAndPasswordFromAuthorizeHeader(Authorization);

        IdentityUser user = await _userManager.FindByNameAsync(username);
        bool successfulAuthentication = await _userManager.CheckPasswordAsync(user, password);

        if (successfulAuthentication)
            return Ok();
        else
            return Unauthorized();
    }
}
Josh Withee
  • 9,922
  • 3
  • 44
  • 62
  • This is a great example! I wish Microsoft would just use this instead of all their umpteen different Identity solutions over the last 20 years. I would also add an extra "WWW-Authenticate" response header before the Unauthorized to show people how to get the dialog box to show up. Thanks! – Robert4Real Aug 02 '20 at 04:14
  • 2
    Replace `Constants.ISO_8859_1_ENCODING` with `static Encoding ISO_8859_1_ENCODING = System.Text.Encoding.GetEncoding("ISO-8859-1")` to get it working. – rlv-dan Dec 04 '20 at 07:44
  • Just for little application – Emanuele Apr 05 '22 at 14:54
  • If you just add `Response.Headers.WWWAuthenticate = "Basic";` above `return Unauthorized();`, the browser will ask the user for a password and try again. – Christian Davén Feb 27 '23 at 15:22
6

We implemented Digest security for an internal service by using an ActionFilter:

public class DigestAuthenticationFilterAttribute : ActionFilterAttribute
{
    private const string AUTH_HEADER_NAME = "Authorization";
    private const string AUTH_METHOD_NAME = "Digest ";
    private AuthenticationSettings _settings;

    public DigestAuthenticationFilterAttribute(IOptions<AuthenticationSettings> settings)
    {
        _settings = settings.Value;
    }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        ValidateSecureChannel(context?.HttpContext?.Request);
        ValidateAuthenticationHeaders(context?.HttpContext?.Request);
        base.OnActionExecuting(context);
    }

    private void ValidateSecureChannel(HttpRequest request)
    {
        if (_settings.RequireSSL && !request.IsHttps)
        {
            throw new AuthenticationException("This service must be called using HTTPS");
        }
    }

    private void ValidateAuthenticationHeaders(HttpRequest request)
    {
        string authHeader = GetRequestAuthorizationHeaderValue(request);
        string digest = (authHeader != null && authHeader.StartsWith(AUTH_METHOD_NAME)) ? authHeader.Substring(AUTH_METHOD_NAME.Length) : null;
        if (string.IsNullOrEmpty(digest))
        {
            throw new AuthenticationException("You must send your credentials using Authorization header");
        }
        if (digest != CalculateSHA1($"{_settings.UserName}:{_settings.Password}"))
        {
            throw new AuthenticationException("Invalid credentials");
        }

    }

    private string GetRequestAuthorizationHeaderValue(HttpRequest request)
    {
        return request.Headers.Keys.Contains(AUTH_HEADER_NAME) ? request.Headers[AUTH_HEADER_NAME].First() : null;
    }

    public static string CalculateSHA1(string text)
    {
        var sha1 = System.Security.Cryptography.SHA1.Create();
        var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(text));
        return Convert.ToBase64String(hash);
    }
}

Afterwards you can annotate the controllers or methods you want to be accessed with Digest security:

[Route("api/xxxx")]
[ServiceFilter(typeof(DigestAuthenticationFilterAttribute))]
public class MyController : Controller
{
    [HttpGet]
    public string Get()
    {
        return "HELLO";
    }

}

To implement Basic security, simply change the DigestAuthenticationFilterAttribute to not use SHA1 but direct Base64 decoding of the Authorization header.

BenWurth
  • 790
  • 1
  • 7
  • 23
  • 5
    FYI to those considering this. The downside with this approach is that this filter (Authentication) happens very late -- it runs after Authorization filters run, rendering Authorization filters useless. – Clay Lenhart Jun 06 '17 at 13:54