1

I have a web forms app currently using either forms authentication (or LDAP which then sets a FormsAuthenticationTicket cookie). I need to add SSO to this project and I'm currently using OpenID/Azure AD to authenticate with. I have the following Startup.cs configured.

     public void Configuration(IAppBuilder app)
    { 
        string appId = "<id here>";
        string aadInstance = "https://login.microsoftonline.com/{0}";
        string tenant = "<tenant here>"; 
        string postLogoutRedirectUri = "https://localhost:21770/";
        string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);

 app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());

            app.UseOpenIdConnectAuthentication(
             new OpenIdConnectAuthenticationOptions
             {
                 ClientId = appId,
                 Authority = authority,
                 PostLogoutRedirectUri = postLogoutRedirectUri,
                 Notifications = new OpenIdConnectAuthenticationNotifications
                 {
                     SecurityTokenReceived = context =>
                     {
                         System.Diagnostics.Debug.WriteLine("SecurityTokenReceived");
                         return Task.FromResult(0);
                     },

                     SecurityTokenValidated = async n =>
                     {
                         var claims_to_exclude = new[]
                         {
                             "aud", "iss", "nbf", "exp", "nonce", "iat", "at_hash"
                         };

                         var claims_to_keep =
                             n.AuthenticationTicket.Identity.Claims 
                             .Where(x => false == claims_to_exclude.Contains(x.Type)).ToList();
                         claims_to_keep.Add(new Claim("id_token", n.ProtocolMessage.IdToken));

                         if (n.ProtocolMessage.AccessToken != null)
                         {
                             claims_to_keep.Add(new Claim("access_token", n.ProtocolMessage.AccessToken));

                             //var userInfoClient = new UserInfoClient(new Uri("https://localhost:44333/core/connect/userinfo"), n.ProtocolMessage.AccessToken);
                             //var userInfoResponse = await userInfoClient.GetAsync();
                             //var userInfoClaims = userInfoResponse.Claims
                             //    .Where(x => x.Item1 != "sub") // filter sub since we're already getting it from id_token
                             //    .Select(x => new Claim(x.Item1, x.Item2));
                             //claims_to_keep.AddRange(userInfoClaims);
                         }

                         var ci = new ClaimsIdentity(
                             n.AuthenticationTicket.Identity.AuthenticationType,
                             "name", "role");
                         ci.AddClaims(claims_to_keep);

                         n.AuthenticationTicket = new AuthenticationTicket(
                             ci, n.AuthenticationTicket.Properties
                         );
                     },
                     MessageReceived = context =>
                     {
                         System.Diagnostics.Debug.WriteLine("MessageReceived");
                         return Task.FromResult(0);
                     },
                     AuthorizationCodeReceived = context =>
                     {
                         System.Diagnostics.Debug.WriteLine("AuthorizationCodeReceived"); 
                         return Task.FromResult(0);
                     },
                     AuthenticationFailed = context =>
                     {
                         System.Diagnostics.Debug.WriteLine("AuthenticationFailed");
                         context.HandleResponse();
                         context.Response.Write(  context.Exception.Message);
                         return Task.FromResult(0);
                     }
                     ,
                     RedirectToIdentityProvider = (context) =>
                     {
                         System.Diagnostics.Debug.WriteLine("RedirectToIdentityProvider"); 
                         //string currentUrl = context.Request.Scheme + "://" + context.Request.Host + context.Request.Path;
                         //context.ProtocolMessage.RedirectUri = currentUrl;

                         return Task.FromResult(0);
                     }
                 }
             }); 
            app.UseStageMarker(PipelineStage.Authenticate);

        }

I have placed this in page Load event of my master (although it never seems to be getting hit - something else must be causing the authentication process to kick off when I navigate to a page requiring authentication.)

   if (!Request.IsAuthenticated)
                {
                    HttpContext.Current.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "/Login.aspx" }, OpenIdConnectAuthenticationDefaults.AuthenticationType);
                }

My Azure settings are all correct because I am hitting SecurityTokenValidated and AuthorizationCodeReceived functions - I can see my email I am logged in with in the claims information, but I am not sure what to do next. As is I have a never ending loop of authentication requests. I am assuming this is because I have not translated the claim information I have received back into forms authentication ? I attempted to add a dummy auth ticket to the response in AuthorizationCodeReceived but that didn't appear to change anything - I am still getting the looping authentication requests.

FormsAuthenticationTicket authTicket = new FormsAuthenticationTicket(1, "<UserName>", DateTime.Now, DateTime.Now.AddMinutes(60), true,"");
String encryptedTicket = FormsAuthentication.Encrypt(authTicket); 
context.Response.Cookies.Append(FormsAuthentication.FormsCookieName, encryptedTicket);
Anna Forrest
  • 1,711
  • 14
  • 21
  • Is the problem that you have a "never ending loop of authentication requests"? From memory, I'm pretty sure I didn't need to use any explicit logon code - it was all handled by adding the right stuff to the startup.cs. Perhaps the best solution is to create a brand new project using OpenID and take a look at the code that it autogenerates. – Nick.Mc Nov 01 '17 at 02:05
  • the problem is two fold - the never ending loop plus the fact that I need to merry whoever logged into AD to the users I know about, what roles they've been given in my app etc. (assuming this will be a straight email address comparison). I will look at a new project though too. – Anna Forrest Nov 01 '17 at 02:22

2 Answers2

1

This is not a clear cut answer but it's too big for a comment.

I'm using "Organizational Accounts" (i.e. O365 email logins) and I had two big problems (both solved).

First Issue

intermittently, when logging in it would go into an endless redirect loop back and forth between two pages (This didn't happen all the time - only after half an hour testing and logging in and out).

If I left it long enough it would say "query string too long". There is a lot of long winded explanation around cookies and stuff but I had difficulties solving it. In the end it was solved simply by forcing https instead of http

I don't think that's your issue as it seems like it does it everytime. Perhaps have a read through this

New Asp.Net MVC5 project produces an infinite loop to login page

One answer says:

Do not call a protected web API (any web API which requires Authorization) from an authorization page such as ~/Account/Login (which, by itself, does NOT do this.). If you do you will enter into an infinite redirect loop on the server-side.

Second Issue

So the next thing was: our existing authorisation system was sitting in a classic login/pwd table in our database (with an unencrypted password field >:| ). So I needed to pick up the login email and match that to a role defined in this table. Which I did thanks to the guy who answered my question:

Capturing login event so I can cache other user information

This answer meant that I could:

  1. Go pick up the users role from the database once upon initial login
  2. Save this role inside the existing native C# security object
  3. Best of all: use the native authorisation annotations in my controller methods without any custom code in the method

I think thats what you are after but the question really is: how are you currently storing roles? In a database table? In Active Directory? In Azure active directory?

Nick.Mc
  • 18,304
  • 6
  • 61
  • 91
  • Users/Roles are currently stored in the aspnet_Users/aspnet_UsersInRoles/aspnet_Roles tables in the app's database. The app then uses System.Web.Security.Membership.GetUser() and System.Web.Security.Roles.IsUserInRole("") everywhere to do its work in the various pages. I don't really want to touch any of that if I can help it, hence trying to merry up the authenticated user and the user my app knows :) – Anna Forrest Nov 01 '17 at 06:35
  • So if I understand correctly you have an existing ASP.Net app using forms security (that's what those tables are) and you would like to change it to use organizational security (O365 logins), but you want to reuse all of the existing role definitions already in the user database. You also don't want to change any of your existing code. – Nick.Mc Nov 01 '17 at 08:11
  • This is all new to me, so I think yes. In the long term I expect to have to add support for other identity providers - the azure directory auth is just the first option they want to add to the system. And Forms security is never going to go away totally in my app - there will always be system users that don't have a login for Azure or any other source so I am really just looking for the easiest way to make them all play nice together. – Anna Forrest Nov 01 '17 at 08:23
  • Referring to this page: https://stackoverflow.com/questions/38999304/mixing-azure-ad-authentication-with-forms-authentication I have a very basic request/response/log-in happening without using the owin libraries at all. In this attempt I have manually constructed the GET request for login.microsoftonline.com, parsed the returned token and then constructed the FormsAuthenticationTicket object as per the question before redirecting to my secured page. That does appear to work - but not sure if it is the best way? – Anna Forrest Nov 01 '17 at 08:27
  • I'm a bit out of my depth but I would've thought you should be able to arrive at a 'security object' populated with a user id without doing any manual processes..... not sure though – Nick.Mc Nov 01 '17 at 08:45
1

So in the hope that it help someone else - this is what I ended up with. In the web.config, the authentication mode is set to 'Forms'. I added the following Startup.cs

  public class Startup
    {
        public void Configuration(IAppBuilder app)
        {

        var appId = ConfigurationCache.GetConfigurationString(TOS_Configuration.KEY_SSO_APPID);
        var authority = ConfigurationCache.GetConfigurationString(TOS_Configuration.KEY_SSO_AUTHORITY);

        app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
        app.UseCookieAuthentication(new CookieAuthenticationOptions());

        app.UseOpenIdConnectAuthentication(
         new OpenIdConnectAuthenticationOptions
         {
             ClientId = appId,
             Authority = authority,
             Notifications = new OpenIdConnectAuthenticationNotifications
             {
                 AuthorizationCodeReceived = context =>
                 {

                     string username = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Name).Value; 

                     FormsAuthenticationTicket authTicket = new FormsAuthenticationTicket(1, username, DateTime.Now, DateTime.Now.AddMinutes(60), true, "");
                     String encryptedTicket = FormsAuthentication.Encrypt(authTicket);
                     context.Response.Cookies.Append(FormsAuthentication.FormsCookieName, encryptedTicket);

                     return Task.FromResult(0);
                 },
                 AuthenticationFailed = context =>
                 {
                     context.HandleResponse();
                     context.Response.Write(context.Exception.Message);
                     return Task.FromResult(0);
                 }
             }
         });

        // This makes any middleware defined above this line run before the Authorization rule is applied in web.config
        app.UseStageMarker(PipelineStage.Authenticate);

    }

}

I did not add any challenge to my site master pages and instead added the following to my login page to trigger the authentication challenge:

if (!Request.IsAuthenticated && AttemptSSO)
{
    ReturnURL = Request.QueryString["ReturnUrl"];
    HttpContext.Current.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = "/Login.aspx" }, OpenIdConnectAuthenticationDefaults.AuthenticationType);
 }
 else if (Request.IsAuthenticated && AttemptSSO)
 {
     if (!string.IsNullOrEmpty(ReturnURL))
     {
           var url = ReturnURL;
           ReturnURL = "";
           Response.Redirect(ResolveUrl(url));
     }
     else
     {
            Response.Redirect(ResolveUrl("~/Default.aspx"));
     }
 }

This means that if a user arrives at a authenticated page without a valid forms authentication token they get redirected to the login page. The login page takes care of deciding if SSO is set up and handling it appropriately. If anyone has any thoughts as to how to improve it - I'd love to hear them, but for the moment this does work.

Anna Forrest
  • 1,711
  • 14
  • 21