Search code examples
c#azureasp.net-coreazure-active-directoryazure-ad-msal

ASP.NET Core 6 : permit Azure AD authentication and local authentication


I am working on a program which will allow a user to authenticate in two ways. They can create and use a local (homebrew) account with any email; or they can use Azure AD OAuth for our organization only. No matter what method of authentication is used the user should be treated the same and return true on context.User.Identity.IsAuthenticated.

I have been running into issues where only the Azure AD method worked, to fix this I used authentication policies inspired by this article. However, after following it both authentication methods seem to not be working :(

Here is the services code in startup.cs:

services.AddJwtAuthorization();

services.AddAuthentication(o =>
{
    o.DefaultScheme = "MultiAuthSchemes";
    o.DefaultChallengeScheme = "MultiAuthSchemes";
})
.AddCookie(o =>
{
    o.LoginPath = "/login";
})
.AddJwtBearer("HomebrewScheme", _ => { })
.AddPolicyScheme("MultiAuthSchemes", JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.ForwardDefaultSelector = context =>
    {
        string authorization = context.Request.Headers[HeaderNames.Authorization];
        if (!string.IsNullOrEmpty(authorization) && authorization.Contains("Bearer "))
        {
            var token = authorization["Bearer ".Length..].Trim();
            var jwtHandler = new JwtSecurityTokenHandler();
            return jwtHandler.CanReadToken(token)
                ? "HomebrewScheme" : "AdScheme";
        }
        return CookieAuthenticationDefaults.AuthenticationScheme;
    };
})
.AddMicrosoftIdentityWebApi(Config, "AzureAd", "AdScheme");

services.AddAuthorization(o =>
{
    var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
        JwtBearerDefaults.AuthenticationScheme,
        CookieAuthenticationDefaults.AuthenticationScheme,
        "HomebrewScheme", "AdScheme");
    defaultAuthorizationPolicyBuilder =
        defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
    o.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
    
    var onlySecondJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("HomebrewScheme");
    o.AddPolicy("OnlyHomebrewScheme", onlySecondJwtSchemePolicyBuilder
        .RequireAuthenticatedUser()
        .AddAuthenticationSchemes()
        .Build());
    var onlyCookieSchemePolicyBuilder = new AuthorizationPolicyBuilder("AdScheme");
    o.AddPolicy("OnlyAdScheme", onlyCookieSchemePolicyBuilder
        .RequireAuthenticatedUser()
        .Build());
});

Here is the app code in startup.cs:

app.UseAuthentication()
   
app.UseGraphQLPlayground(new PlaygroundOptions
{
    GraphQLEndPoint = GraphQLApiEndpoint
});
app.UseWebSockets();
app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(x => x.MapGraphQL(path: GraphQLApiEndpoint));

Here is code in startup.cs which I am using to test authentication:

app.Use((context, next) =>
{
    //Grab the first identity because the authentication type does not matter
    if (context.User.Identity?.IsAuthenticated == true)
    {
        PermissionLevel = Permissions.Authorized;
    }
});

From what I can tell from debugging, the system never detects that I obtain authentication now. I know the front end works because if I use only services.AddMicrosoftIdentityWebApiAuthentication(Config, "AzureAd", "AdScheme"); no matter if it is the default scheme or not, everything works.

Thank you for your help, stuck on this one 😊


Solution

  • Firstly, I followed this blog to add Cookie authentication in my code. It provides a login page to let us sign in. And in the AccountController, it provides several mock user account so that we can use them to sign in for test.

    Then I added code in the login page so that it provides the option to sign in with AAD.

    Changing the Program.cs file to add multiple authentication scheme.

    Here's my code, Program.cs

    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.AspNetCore.Authentication.OpenIdConnect;
    using Microsoft.Identity.Web;
    using Microsoft.Identity.Web.UI;
    
    var builder = WebApplication.CreateBuilder(args);
    
    //set CookieAuthenticationDefaults.AuthenticationScheme as the default authentication scheme
    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(x => x.LoginPath = "/account/login");
    builder.Services.AddAuthentication()
        .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"), OpenIdConnectDefaults.AuthenticationScheme, "ADCookies");
    
    // Add microsoft sign in page
    builder.Services.AddControllersWithViews().AddMicrosoftIdentityUI();
    
    var app = builder.Build();
    
    // Configure the HTTP request pipeline.
    if (!app.Environment.IsDevelopment())
    {
        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.UseAuthentication();
    app.UseAuthorization();
    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
    app.Run();
    

    My HomeController

    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using System.Diagnostics;
    using WebAppMvcCookieAuthAad.Models;
    
    namespace WebAppMvcCookieAuthAad.Controllers
    {
        [AllowAnonymous]
        public class HomeController : Controller
        {
            public IActionResult Index()
            {
                return View();
            }
    
            [Authorize]
            public async Task<IActionResult> ConfidentialDataAsync()
            {
                return View();
            }
        }
    }
    

    My AccountController:

    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.AspNetCore.Authentication;
    using Microsoft.AspNetCore.Mvc;
    using System.Security.Claims;
    using WebAppMvcCookieAuthAad.Models;
    
    namespace WebAppMvcCookieAuthAad.Controllers
    {
        public class AccountController : Controller
        { 
            public List<UserModel> users = null;
            public AccountController()
            {
                users = new List<UserModel>();
                users.Add(new UserModel()
                {
                    UserId = 1,
                    Username = "Tiny",
                    Password = "123",
                    Role = "Admin"
                });
                users.Add(new UserModel()
                {
                    UserId = 2,
                    Username = "Other",
                    Password = "123",
                    Role = "User"
                });
            }
            public IActionResult Login(string ReturnUrl = "/")
            {
                LoginModel objLoginModel = new LoginModel();
                objLoginModel.ReturnUrl = ReturnUrl;
                return View(objLoginModel);
            }
            [HttpPost]
            public async Task<IActionResult> Login(LoginModel objLoginModel)
            {
                if (ModelState.IsValid)
                {
                    var user = users.Where(x => x.Username == objLoginModel.UserName && x.Password == objLoginModel.Password).FirstOrDefault();
                    if (user == null)
                    { 
                        ViewBag.Message = "Invalid Credential";
                        return View(objLoginModel);
                    }
                    else
                    {
                        var claims = new List<Claim>() {
                        new Claim(ClaimTypes.NameIdentifier, Convert.ToString(user.UserId)),
                            new Claim(ClaimTypes.Name, user.Username),
                            new Claim(ClaimTypes.Role, user.Role),
                            new Claim("FavoriteDrink", "Tea")
                        };
                        var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
                        var principal = new ClaimsPrincipal(identity);
                        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties()
                        {
                            IsPersistent = objLoginModel.RememberLogin
                        });
                        return LocalRedirect(objLoginModel.ReturnUrl);
                    }
                }
                return View(objLoginModel);
            }
            public async Task<IActionResult> LogOut()
            {
                await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                return LocalRedirect("/");
            }
        }
    }
    

    My LoginModel and UserModel

    using System.ComponentModel.DataAnnotations;
    
    namespace WebAppMvcCookieAuthAad.Models
    {
        public class LoginModel
        {
            [Required]
            [Display(Name = "Username")]
            public string UserName{get;set;}
            [Required]
            [DataType(DataType.Password)]
            public string Password{get;set;}
            public bool RememberLogin{get;set;}
            public string ReturnUrl{get;set;}
        }
    }
    
    namespace WebAppMvcCookieAuthAad.Models
    {
        public class UserModel
        {
            public int UserId { get; set; }
            public string Username { get; set; }
            public string Password { get; set; }
            public string Role { get; set; }
        }
    }
    

    View -> Account -> Login.cshtml:

    @model WebAppMvcCookieAuthAad.Models.LoginModel
    @{
        ViewData["Title"] = "Login";
        Layout = "~/Views/Shared/_Layout.cshtml";
    }
    <h2>Login</h2>
    <hr />
    <div class="row">
        <div class="col-md-4">
            <form asp-action="Login">
                <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                @if (!string.IsNullOrEmpty(ViewBag.Message))
                {
                    <span class="text-danger">
                        @ViewBag.Message
                    </span>
                }
                @Html.HiddenFor(x => x.ReturnUrl)
                <div class="form-group">
                    <label asp-for="UserName" class="control-label"></label>
                    <input asp-for="UserName" class="form-control" />
                    <span asp-validation-for="UserName" class="text-danger"></span>
                </div>
                <div class="form-group">
                    <label asp-for="Password" class="control-label"></label>
                    <input asp-for="Password" class="form-control" />
                    <span asp-validation-for="Password" class="text-danger"></span>
                </div>
                <div class="form-group">
                    <div class="checkbox">
                        <label>
                            <input asp-for="RememberLogin" /> @Html.DisplayNameFor(model => model.RememberLogin)
                        </label>
                    </div>
                </div>
                <div class="form-group">
                    <input type="submit" value="Login" />
                </div>
            </form>
        </div>
    </div>
    
    <div>
        <label>sign in with aad</label>
        <a asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in with aad</a>
    </div>
    

    View -> Home -> ConfidentialData.cshtml

    @if(User.Identity.IsAuthenticated){
        <table>
            @foreach (var item in User.Claims)
            {
                <tr><td>@item.Type</td><td>@item.Value</td></tr>
            }
        </table>
    }
    

    View -> Shared -> _LoginPartial.cshtml, pls don't forget add this partial view to layout.

    @using System.Security.Principal
    
    <ul class="navbar-nav">
        @if (User.Identity.IsAuthenticated)
        {
            <li class="nav-item">
                <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
            </li>
            <li class="nav-item">
                <a class="nav-link text-dark" asp-controller="Account" asp-action="LogOut">log out</a>
            </li>
            @*asp - area = "MicrosoftIdentity"*@
        }
        else
        {
            <li class="nav-item">
                <a class="nav-link text-dark" asp-controller="Account" asp-action="Login">log in</a>
            </li>
        }
    </ul>
    

    appsetting.json:

    {
      "AzureAd": {
        "Instance": "https://login.microsoftonline.com/",
        "Domain": "tenant_name",
        "TenantId": "tenant_id",
        "ClientId": "azure_ad_app_id",
        "ClientSecret": "azure_ad_client_secret",
        "CallbackPath": "/home", //don't forget to set redirect url in azure portal
        "SignedOutCallbackPath ": "/signout-callback-oidc"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*"
    }
    

    This code worked well in my side and I can sign in with cookie auth and aad. I noticed that after signing with aad, @User.Identity.Name won't show user name. But actually the sign in flow succeed.

    enter image description here