asp.net-coreauthenticationbasic-authenticationasp.net-core-identity

How do I add basic authentication on certain API calls in an ASP.NET Core website using Identity


I have an ASP.NET Core web application that is using Identity for authentication and authorization. This allows me to register users easily in the website and it all works as expected.

However, the website also publishes a number of API endpoints that I would like to make available using basic authentication, where that basic authentication uses the same database of users/password hashes that are stored against the main website, but controlled by a specific role within Identity.

So I'd assign that role to certain users and they could access the API through basic authentication. The reason for this is that the client that accesses these API's can only use basic authentication and nothing else.

How would I go about adding basic authentication to these specific endpoints, but leave the rest of the website as is?


Solution

  • We can implement this feature by adding custom BasicAuthenticationHandler.

    Here are the detailed steps - BasicAuthenticationHandler.cs:

    using Microsoft.AspNetCore.Authentication;
    using Microsoft.AspNetCore.Identity;
    using Microsoft.Extensions.Options;
    using System.Security.Claims;
    using System.Text;
    using System.Text.Encodings.Web;
    
    namespace _79547481
    {
        public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
        {
            private readonly UserManager<IdentityUser> _userManager;
    
            public BasicAuthenticationHandler(
                IOptionsMonitor<AuthenticationSchemeOptions> options,
                ILoggerFactory logger,
                UrlEncoder encoder,
                ISystemClock clock,
                UserManager<IdentityUser> userManager)
                : base(options, logger, encoder, clock)
            {
                _userManager = userManager;
            }
    
            protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
            {
                if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
                    return AuthenticateResult.NoResult();
    
                if (!authHeader.ToString().StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
                    return AuthenticateResult.NoResult();
    
                try
                {
                    var encodedCredentials = authHeader.ToString()["Basic ".Length..].Trim();
                    var decodedBytes = Convert.FromBase64String(encodedCredentials);
                    var decodedCredentials = Encoding.UTF8.GetString(decodedBytes);
                    var separatorIndex = decodedCredentials.IndexOf(':');
    
                    if (separatorIndex < 0)
                        return AuthenticateResult.Fail("Invalid credentials format");
    
                    var username = decodedCredentials[..separatorIndex];
                    var password = decodedCredentials[(separatorIndex + 1)..];
    
                    var user = await _userManager.FindByNameAsync(username);
                    if (user == null || !await _userManager.CheckPasswordAsync(user, password))
                        return AuthenticateResult.Fail("Invalid username or password");
    
                    var roles = await _userManager.GetRolesAsync(user);
                    if (!roles.Contains("APIRole"))
                        return AuthenticateResult.Fail("Access denied");
    
                    var claims = new List<Claim>
                {
                    new(ClaimTypes.NameIdentifier, user.Id),
                    new(ClaimTypes.Name, user.UserName),
                    new(ClaimTypes.Role, "APIRole")
                };
    
                    var identity = new ClaimsIdentity(claims, Scheme.Name);
                    var principal = new ClaimsPrincipal(identity);
                    return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
                }
                catch
                {
                    return AuthenticateResult.Fail("Authentication error");
                }
            }
        }
    }
    

    Program.cs:

    using Microsoft.AspNetCore.Identity;
    using Microsoft.EntityFrameworkCore;
    using _79547481.Data;
    using _79547481;
    using Microsoft.AspNetCore.Authentication;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // Add services to the container.
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
    builder.Services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(connectionString));
    builder.Services.AddDatabaseDeveloperPageExceptionFilter();
    
    builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
        .AddRoles<IdentityRole>() //Add this line to fix the error message `Store does not implement IUserRoleStore<TUser>.`
        .AddEntityFrameworkStores<ApplicationDbContext>();
    
    // Add BasicAuthentication
    builder.Services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
        options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
    })
    .AddCookie()
    .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);
    
    builder.Services.AddAuthorization(options =>
    {
        options.AddPolicy("ApiAccess", policy =>
            policy.RequireAuthenticatedUser()
                  .RequireRole("APIRole"));
    });
    
    builder.Services.AddControllersWithViews();
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
        app.UseMigrationsEndPoint();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }
    
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    
    app.UseRouting();
    
    app.UseAuthorization();
    
    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
    app.MapRazorPages();
    
    app.Run();
    

    Test code in controller:

    using System.Diagnostics;
    using Microsoft.AspNetCore.Mvc;
    using _79547481.Models;
    using Microsoft.AspNetCore.Authorization;
    
    namespace _79547481.Controllers;
    
    [ApiController]
    [Route("api/[controller]")]
    public class DataController : ControllerBase
    {
        private readonly ILogger<DataController> _logger;
    
        public DataController(ILogger<DataController> logger)
        {
            _logger = logger;
        }
    
        [HttpGet("secure")]
        [Authorize(AuthenticationSchemes = "BasicAuthentication", Policy = "ApiAccess")]
        public IActionResult GetSecureData()
        {
            return Ok(new { data = "Sensitive information" });
        }
    }
    

    Test result:

    enter image description here