Search code examples
asp.net-mvcazureasp.net-identityazure-active-directory

How can I support both Individual User Accounts and Work or School Accounts in a single Microsoft ASP.NET MVC web application?


I want to create an ASP.NET MVC web application where user authentication mechanism is based on user type. So if clicks on Individual Account then he will be redirected to the login page of the application. If he clicks on the Corporate Account then he will be redirected to the Azure AD login page. Effectively, the application will support both form-based ASP.NET Identity authentication and multi-tenant Azure AD authentication.

What's the best way to achieve this kind of functionality in a single ASP.NET MVC web application? Specifically, what changes I need to make to the middleware code in the Startup class?

ASP.NET MVC Version: 5.2

.NET Framework: 4.7

Visual Studio IDE: 2017 Community

For reference, I get the following code if I choose Individual User Accounts template when creating a brand new web application:

// For more information on configuring authentication, please visit https://go.microsoft.com/fwlink/?LinkId=301864
public void ConfigureAuth(IAppBuilder app)
{
    // Configure the db context, user manager and signin manager to use a single instance per request
    app.CreatePerOwinContext(ApplicationDbContext.Create);
    app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
    app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);

    // Enable the application to use a cookie to store information for the signed in user
    // and to use a cookie to temporarily store information about a user logging in with a third party login provider
    // Configure the sign in cookie
    app.UseCookieAuthentication(new CookieAuthenticationOptions
    {
        AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
        LoginPath = new PathString("/Account/Login"),
        Provider = new CookieAuthenticationProvider
        {
            // Enables the application to validate the security stamp when the user logs in.
            // This is a security feature which is used when you change a password or add an external login to your account.  
            OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
                validateInterval: TimeSpan.FromMinutes(30),
                regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
        }
    });            
    app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

    // Enables the application to temporarily store user information when they are verifying the second factor in the two-factor authentication process.
    app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5));

    // Enables the application to remember the second login verification factor such as phone or email.
    // Once you check this option, your second step of verification during the login process will be remembered on the device where you logged in from.
    // This is similar to the RememberMe option when you log in.
    app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

    // Uncomment the following lines to enable logging in with third party login providers
    //app.UseMicrosoftAccountAuthentication(
    //    clientId: "",
    //    clientSecret: "");

    //app.UseTwitterAuthentication(
    //   consumerKey: "",
    //   consumerSecret: "");

    //app.UseFacebookAuthentication(
    //   appId: "",
    //   appSecret: "");

    //app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
    //{
    //    ClientId = "",
    //    ClientSecret = ""
    //});
}

The following is the code if I choose Azure AD login (multiple organisations):

private static string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
private string appKey = ConfigurationManager.AppSettings["ida:ClientSecret"];
private string graphResourceID = "https://graph.windows.net";
private static string aadInstance = EnsureTrailingSlash(ConfigurationManager.AppSettings["ida:AADInstance"]);
private string authority = aadInstance + "common";
private ApplicationDbContext db = new ApplicationDbContext();

public void ConfigureAuth(IAppBuilder app)
{

    app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

    app.UseCookieAuthentication(new CookieAuthenticationOptions { });

    app.UseOpenIdConnectAuthentication(
        new OpenIdConnectAuthenticationOptions
        {
            ClientId = clientId,
            Authority = authority,
            TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
            {
                // instead of using the default validation (validating against a single issuer value, as we do in line of business apps), 
                // we inject our own multitenant validation logic
                ValidateIssuer = false,
            },
            Notifications = new OpenIdConnectAuthenticationNotifications()
            {
                SecurityTokenValidated = (context) => 
                {
                    return Task.FromResult(0);
                },
                AuthorizationCodeReceived = (context) =>
                {
                    var code = context.Code;

                    ClientCredential credential = new ClientCredential(clientId, appKey);
                    string tenantID = context.AuthenticationTicket.Identity.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
                    string signedInUserID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;

                    AuthenticationContext authContext = new AuthenticationContext(aadInstance + tenantID, new ADALTokenCache(signedInUserID));
                    AuthenticationResult result = authContext.AcquireTokenByAuthorizationCodeAsync(
                        code, new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path)), credential, graphResourceID).Result;

                    return Task.FromResult(0);
                },
                AuthenticationFailed = (context) =>
                {
                    context.OwinContext.Response.Redirect("/Home/Error");
                    context.HandleResponse(); // Suppress the exception
                    return Task.FromResult(0);
                }
            }
        });

}

private static string EnsureTrailingSlash(string value)
{
    if (value == null)
    {
        value = string.Empty;
    }

    if (!value.EndsWith("/", StringComparison.Ordinal))
    {
        return value + "/";
    }

    return value;
}

Solution

  • Assuming you have a controller at least named Home or anything you need to use the owin middleware.

    if the individual accounts are used to access the application following action will be used.

    1. Login (to login to the application using local or any type of authentication)
    2. Logout (to sign out from the application)

    I am not going into details of the login and logout as I thought you have implemented them already.

    if want to log in from any school or organization account you must have these actions as well

     public void SignIn()
        {
                HttpContext.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "/" }, OpenIdConnectAuthenticationDefaults.AuthenticationType);
        }
    
        public void SignOut()
        {
            // Send an OpenID Connect sign-out request.
            HttpContext.GetOwinContext().Authentication.SignOut(
                OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);
        }
        public void EndSession()
        {
            // If AAD sends a single sign-out message to the app, end the user's session, but don't redirect to AAD for sign out.
            HttpContext.GetOwinContext().Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
        }
    
        public void UserNotBelongToSystem()
        {
            // If AAD sends a single sign-out message to the app, end the user's session, but don't redirect to AAD for sign out.
            HttpContext.GetOwinContext().Authentication.SignOut(
               OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);
        }
    

    then comes the startup class

     public  partial class Startup
    {
    }
    

    Below are namespaces used.

    using Castle.MicroKernel.Registration;
    using Microsoft.IdentityModel.Protocols;
    using Microsoft.Owin.Security;
    using Microsoft.Owin.Security.Cookies;
    using Microsoft.Owin.Security.OpenIdConnect;
    using System.Collections.Generic;
    using System.Configuration;
    using System.Globalization;
    using System.Runtime.Serialization;
    using System.Security.Claims;
    

    there is a method in the startup as

     private static Task RedirectToIdentityProvider(Microsoft.Owin.Security.Notifications.RedirectToIdentityProviderNotification<Microsoft.IdentityModel.Protocols.OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> arg)
            {
                string appBaseUrl = arg.Request.Scheme + "://" + arg.Request.Host + arg.Request.PathBase;
                arg.ProtocolMessage.RedirectUri = appBaseUrl + "/";
                arg.ProtocolMessage.PostLogoutRedirectUri = appBaseUrl;
                arg.ProtocolMessage.Prompt = "login";
                if (arg.ProtocolMessage.State != null)
                {
                    var stateQueryString = arg.ProtocolMessage.State.Split('=');
                    var protectedState = stateQueryString[1];
                    var state = arg.Options.StateDataFormat.Unprotect(protectedState);
                    state.Dictionary.Add("mycustomparameter", UtilityFunctions.Encrypt("myvalue"));
                    arg.ProtocolMessage.State = stateQueryString[0] + "=" + arg.Options.StateDataFormat.Protect(state);
                }
                return Task.FromResult(0);
            }
    

    by doing above you are sending your custom value to azure authehtication provider that this request was orignally generated by you.

    because once you receive the response you need to verify that this was you who redirected the request to the identity provider for authentication

     private static Task OnMessageReceived(Microsoft.Owin.Security.Notifications.MessageReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
            {
                if (notification.ProtocolMessage.State != null)
                {
                    string mycustomparameter;
                    var protectedState = notification.ProtocolMessage.State.Split('=')[1];
                    var state = notification.Options.StateDataFormat.Unprotect(protectedState);
                    state.Dictionary.TryGetValue("mycustomparameter", out mycustomparameter);
                    if (UtilityFunctions.Decrypt(mycustomparameter) != "myvalue")
                        throw new System.IdentityModel.Tokens.SecurityTokenInvalidIssuerException();
                }
                return Task.FromResult(0);
            }
    

    following are the method to have in order to log out or handle the user in case of the expired token in startup

     private Task AuthenticationFailed(Microsoft.Owin.Security.Notifications.AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
            {
                //context.HandleResponse();
                //context.Response.Redirect("/Error?message=" + context.Exception.Message);
                return Task.FromResult(0);
            }
    
            private Task SecurityTokenValidated(Microsoft.Owin.Security.Notifications.SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
            {
                string userID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Name).Value;            
                return Task.FromResult(0);
            }
    

    and after that, once you the response was validated in the OnMessageReceived method the claims added to the authetication token

        private Task AuthorizationCodeReceived(Microsoft.Owin.Security.Notifications.AuthorizationCodeReceivedNotification context)
            {
                var code = context.Code;
                string userID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Name).Value;
    
                var _objUser = Users.CheckIfTheUserExistInOurSystem(userID);//check this in system again your choice depends upone the requiement you have
                if (_objUser == null)
                {
                    context.HandleResponse();
                  //  context.OwinContext.Authentication.SignOut(OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);
                    context.Response.Redirect("Home/UserNotBelongToSystem");// same mehthod added above as to signout 
                    // throw new System.IdentityModel.Tokens.SecurityTokenValidationException();
                }
                else
                {
                    _objUser.IsAZureAD = true;// setting true to find out at the time of logout where to redirect
                   var claims = Users.GetCurrentUserAllClaims(_objUser);//You can create your claims any way you want just getting from other method. and same was used in case of the normal login
                    context.AuthenticationTicket.Identity.AddClaims(claims);
     context.OwinContext.Authentication.SignIn(context.AuthenticationTicket.Identity);
                    context.HandleResponse();
                    context.Response.Redirect("Home/Main");
                }
    
    }
    

    last but not least you can check the user claims to see if the user currently logged in was from Azure or not in order to redirect to specific signout page

     if (CurrentUser.IsAZureAD) { LogoutUrl = Url.Content("~/Home/SignOut"); } else { LogoutUrl = Url.Content("~/Home/LogOut"); 
    

    CurrentUser.IsAZureAD won't be available to you unless you make a class like this

      public class CurrentUser: ClaimsPrincipal
        {
            public CurrentUser(ClaimsPrincipal principal)
                : base(principal)
            {
            }
    
            public string Name
            {
                get
                {
                    // return this.FindFirst(ClaimTypes.Name).Value;
                    return this.FindFirst("USER_NAME").Value;
                }
            }
    
    
            public bool IsAZureAD
            {
                get
                {
                    return Convert.ToBoolean(this.FindFirst("IsAZureAD").Value);
                }
            }
        }