16

I have a large enterprise application containing both WebForms and MVC pages. It has existing authentication and authorisation settings that I don't want to change.

The WebForms authentication is configured in the web.config:

 <authentication mode="Forms">
  <forms blah... blah... blah />
 </authentication>

 <authorization>
  <deny users="?" />
 </authorization>

Fairly standard so far. I have a REST service that is part of this big application and I want to use HTTP authentication instead for this one service.

So, when a user attempts to get JSON data from the REST service it returns an HTTP 401 status and a WWW-Authenticate header. If they respond with a correctly formed HTTP Authorization response it lets them in.

The problem is that WebForms overrides this at a low level - if you return 401 (Unauthorised) it overrides that with a 302 (redirection to login page). That's fine in the browser but useless for a REST service.

I want to turn off the authentication setting in the web.config, overriding the 'rest' folder:

 <location path="rest">
  <system.web>
   <authentication mode="None" />
   <authorization><allow users="?" /></authorization>
  </system.web>
 </location>

The authorisation bit works fine, but the authentication line (<authentication mode="None" />) causes an exception:

It is an error to use a section registered as allowDefinition='MachineToApplication' beyond application level.

I'm configuring this at application level though - it's in the root web.config - and that error is for web.configs in sub-directories.

How do I override the authentication so that all of the rest of the site uses WebForms authentication and this one directory uses none?

This is similar to another question: 401 response code for json requests with ASP.NET MVC, but I'm not looking for the same solution - I don't want to just remove the WebForms authentication and add new custom code globally, there's far to much risk and work involved. I want to change just the one directory in configuration.

Update

I want to set up a single web application and in that I want all the WebForms pages and MVC views to use WebForms authentication. I want one directory to use basic HTTP authentication.

Note that I'm talking about authentication, not authorisation. I want REST calls to come with the username & password in an HTTP header, and I want WebForm & MVC pages to come with the authentication cookie from .Net - in either case authorisation is done against our DB.

I don't want to rewrite WebForms authentication and roll my own cookies - it seems ridiculous that is the only way to add an HTTP authorised REST service to an application.

I can't add an additional application or virtual directory - it's got to be as one application.

Community
  • 1
  • 1
Keith
  • 150,284
  • 78
  • 298
  • 434
  • Hi Keith - I'm running into something similar and have utilized part of your solution below. Would you recommend a different approach looking back? My situation: http://stackoverflow.com/questions/27785561/configuring-authorization-in-a-mixed-mvc-webforms-web-app – SB2055 Jan 05 '15 at 23:28

7 Answers7

10

If "rest" is simply a folder in your root you are almost there: remove authentication line i.e.

<location path="rest">
  <system.web>
      <authorization>
        <allow users="*" />
      </authorization>
  </system.web>
 </location>

Alternatively you can add a web.config to your rest folder and just have this:

<system.web>
     <authorization>
          <allow users="*" />
     </authorization>
</system.web>

Check this one.

gbs
  • 7,196
  • 5
  • 43
  • 69
  • Yes, 'rest' is just the folder with my REST services in it - I can change the `` just fine. The problem is the `` line - if I take it out my web.config doesn't throw an error, but without it all 401 HTTP authorisations get swallowed by the WebForms setting. Basically I need the `` for HTTP `WWW-Authenticate` to work, but it throws an error in the web.config, regardless of whether it is a folder one or the root one. – Keith Jan 06 '11 at 17:01
  • 1
    You cannot have authentication section for your sub-folder. In that case you will simply need to convert your rest folder to a Virtual Directory with it's own web.config with authentication and authorization. – gbs Jan 06 '11 at 17:15
  • That's not really possible as its all part of the same IIS application - I can change the root web.config, so the sub folder config not being able to overwrite it shouldn't be an issue. The examples in the question are in the root web.config, so there should be a way around it. – Keith Jan 06 '11 at 18:40
  • I am not sure of way to get around your scenario but the error you are getting is 100% due to in the location. That is something you cannot have unless your rest folder is configured as an application. – gbs Jan 06 '11 at 18:53
  • Yes, my error is due to `` - I actually state that in the question. The actual question is: how do I work around that? It seems ridiculous that .Net cannot do this. – Keith Jan 11 '11 at 09:22
  • I've never done it before but maybe try creating the sub directory as a application. – Phill Jan 11 '11 at 09:57
  • @Phill - Yes, that would be a potential way around this. However please read the question as I state that isn't an option for us. – Keith Jan 11 '11 at 11:49
  • @Kenith - For what it's worth, I've had the page open longer than 20 hours so I never saw the edit. :) I got a bunch of interesting questions open I haven't finished reading and such. I thought a sub application could push too and from the root app without issue, it's just it functions as an independent app, so your application would work as normal except the authentication in that folder/app would differ. I dont actually know how it works tho. :) – Phill Jan 11 '11 at 11:54
4

I've worked around this the messy way - by spoofing the Forms authentication in the global.asax for all the existing pages.

I still don't quite have this fully working, but it goes something like this:

protected void Application_BeginRequest(object sender, EventArgs e)
{
    // lots of existing web.config controls for which webforms folders can be accessed
    // read the config and skip checks for pages that authorise anon users by having
    // <allow users="?" /> as the top rule.

    // check local config
    var localAuthSection = ConfigurationManager.GetSection("system.web/authorization") as AuthorizationSection;

    // this assumes that the first rule will be <allow users="?" />
    var localRule = localAuthSection.Rules[0];
    if (localRule.Action == AuthorizationRuleAction.Allow &&
        localRule.Users.Contains("?"))
    {
        // then skip the rest
        return;
    }

    // get the web.config and check locations
    var conf = WebConfigurationManager.OpenWebConfiguration("~");
    foreach (ConfigurationLocation loc in conf.Locations)
    {
        // find whether we're in a location with overridden config
        if (this.Request.Path.StartsWith(loc.Path, StringComparison.OrdinalIgnoreCase) ||
            this.Request.Path.TrimStart('/').StartsWith(loc.Path, StringComparison.OrdinalIgnoreCase))
        {
            // get the location's config
            var locConf = loc.OpenConfiguration();
            var authSection = locConf.GetSection("system.web/authorization") as AuthorizationSection;
            if (authSection != null)
            {
                // this assumes that the first rule will be <allow users="?" />
                var rule = authSection.Rules[0];
                if (rule.Action == AuthorizationRuleAction.Allow &&
                    rule.Users.Contains("?"))
                {
                    // then skip the rest
                    return;
                }
            }
        }
    }

    var cookie = this.Request.Cookies[FormsAuthentication.FormsCookieName];
    if (cookie == null ||
        string.IsNullOrEmpty(cookie.Value))
    {
        // no or blank cookie
        FormsAuthentication.RedirectToLoginPage();
    }

    // decrypt the 
    var ticket = FormsAuthentication.Decrypt(cookie.Value);
    if (ticket == null ||
        ticket.Expired)
    {
        // invalid cookie
        FormsAuthentication.RedirectToLoginPage();
    }

    // renew ticket if needed
    var newTicket = ticket;
    if (FormsAuthentication.SlidingExpiration)
    {
        newTicket = FormsAuthentication.RenewTicketIfOld(ticket);
    }

    // set the user so that .IsAuthenticated becomes true
    // then the existing checks for user should work
    HttpContext.Current.User = new GenericPrincipal(new FormsIdentity(newTicket), newTicket.UserData.Split(','));

}

I'm not really happy with this as a fix - it seems like a horrible hack and re-invention of the wheel, but it looks like this is the only way for my Forms-authenticated pages and HTTP-authenticated REST service to work in the same application.

Keith
  • 150,284
  • 78
  • 298
  • 434
  • Yeah, that's what you have to do to make both modes work together (either in the HttpApplication instance or in an http module). Sorry you have to go this route. I'm still curious about having to keep the REST service in the same application. Can you highlight why you have to do that? I found that to be an interesting constraint. – arcain Jan 18 '11 at 09:18
  • @arcain - we have a lot of IIS applications already running and each one needs to keep a fair amount of stuff in memory, most notably instances of dynamically compiled plug-ins. I want the REST service to use the same resources and not require our hosting guys to have to create and maintain double the IIS applications. – Keith Jan 18 '11 at 09:49
4

I found myself with the same exact problem, the following article pointed me in the right direction: http://msdn.microsoft.com/en-us/library/aa479391.aspx

MADAM does exactly what you are after, specifically, you can configure the FormsAuthenticationDispositionModule to mute the forms authentication "trickery", and stop it from changing the response code from 401 to 302. This should result in your rest client receiving the right auth challenge.

MADAM Download page: http://www.raboof.com/projects/madam/

In my case, the REST calls are made to controllers (this is a MVC based app) in the "API" area. A MADAM discriminator is set with the following configuracion:

<formsAuthenticationDisposition>
  <discriminators all="1">
    <discriminator type="Madam.Discriminator">
      <discriminator
          inputExpression="Request.Url"
          pattern="api\.*" type="Madam.RegexDiscriminator" />
    </discriminator>
  </discriminators>
</formsAuthenticationDisposition>

Then all you have to do is add the MADAM module to your web.config

<modules runAllManagedModulesForAllRequests="true">
  <remove name="WebDAVModule" /> <!-- allow PUT and DELETE methods -->
  <add name="FormsAuthenticationDisposition" type="Madam.FormsAuthenticationDispositionModule, Madam" />
</modules>

Remember to add the valid sections to the web.config (SO didn't let me paste the code), you can get an example from the web project in the download.

With this setup any requests made to URLs starting with "API/" will get a 401 response instead of the 301 produced by the Forms Authentication.

Yosoyadri
  • 551
  • 5
  • 8
  • 1
    Doesn't the regex `api\.*` match 'api' followed by a period any number of times? I think you mean `api/.*`. – Neek Mar 12 '15 at 08:14
2

In .NET 4.5 you can now set

Response.SuppressFormsAuthenticationRedirect = true

Check this page: https://msdn.microsoft.com/en-us/library/system.web.httpresponse.suppressformsauthenticationredirect.aspx

Bebben
  • 735
  • 6
  • 14
2

I was able to get this to work on a previous project, but it did require using an HTTP module to perform the custom basic authentication, since account validation is against a database rather than Windows.

I set up the test as you specified with one one web application at the root of the test website, and a folder containing the REST service. The config for the root application was configured to deny all access:

<authentication mode="Forms">
  <forms loginUrl="Login.aspx" timeout="2880" />
</authentication>
<authorization>
  <deny users="?"/>
</authorization>

I then had to create an application for the REST folder in IIS, and place a web.config file into the REST folder. In that config, I specified the following:

<authentication mode="None"/>
<authorization>
  <deny users="?"/>
</authorization>

I also had to wire up the http module in the appropriate places within the REST directory's config. This module must go into a bin directory under the REST directory. I used Dominick Baier's custom basic authentication module, and that code is located here. That version is more IIS 6 specific, however there is a version for IIS 7 as well on codeplex, but I haven't test that one (warning: the IIS6 version does not have the same assembly name and namespace as the IIS7 version.) I really like this basic auth module since it plugs right into ASP.NET's membership model.

The last step was to ensure that only anonymous access was allowed to both the root application and the REST application within IIS.

I've included the full configs below for completeness. The test app was just a ASP.NET web form application generated from VS 2010, it was using the AspNetSqlProfileProvider for the membership provider; here's the config:

<?xml version="1.0"?>

<configuration>
  <connectionStrings>
    <add name="ApplicationServices"
      connectionString="data source=.\SQLEXPRESS;Integrated Security=SSPI;Database=sqlmembership;"
    providerName="System.Data.SqlClient" />
  </connectionStrings>

  <system.web>
    <compilation debug="true" targetFramework="4.0" />

    <authentication mode="Forms">
      <forms loginUrl="~/Account/Login.aspx" timeout="2880" />
    </authentication>

    <authorization>
      <deny users="?"/>
    </authorization>

    <membership>
      <providers>
        <clear/>
        <add name="AspNetSqlMembershipProvider" type="System.Web.Security.SqlMembershipProvider" connectionStringName="ApplicationServices"
          enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" requiresUniqueEmail="false"
          maxInvalidPasswordAttempts="5" minRequiredPasswordLength="6" minRequiredNonalphanumericCharacters="0" passwordAttemptWindow="10"
        applicationName="/" />
      </providers>
    </membership>

    <profile>
      <providers>
        <clear/>
        <add name="AspNetSqlProfileProvider" type="System.Web.Profile.SqlProfileProvider" connectionStringName="ApplicationServices" applicationName="/"/>
      </providers>
    </profile>

    <roleManager enabled="false">
      <providers>
        <clear/>
        <add name="AspNetSqlRoleProvider" type="System.Web.Security.SqlRoleProvider" connectionStringName="ApplicationServices" applicationName="/" />
        <add name="AspNetWindowsTokenRoleProvider" type="System.Web.Security.WindowsTokenRoleProvider" applicationName="/" />
      </providers>
    </roleManager>

  </system.web>

  <system.webServer>
    <modules runAllManagedModulesForAllRequests="true"/>
  </system.webServer>
</configuration>

The REST directory contained an empty ASP.NET project generated from VS 2010, and I put a single ASPX file into that, however the contents of the REST folder didn't have to be a new project. Just dropping in a config file after the directory has had an application associated with it should work. The config for that project follows:

<?xml version="1.0"?>
<configuration>
  <configSections>
    <section name="customBasicAuthentication" type="Thinktecture.CustomBasicAuthentication.CustomBasicAuthenticationSection, Thinktecture.CustomBasicAuthenticationModule"/>
  </configSections>
  <customBasicAuthentication
    enabled="true"
    realm="testdomain"
    providerName="AspNetSqlMembershipProvider"
    cachingEnabled="true"
    cachingDuration="15"
  requireSSL="false" />

  <system.web>
    <authentication mode="None"/>
    <authorization>
      <deny users="?"/>
    </authorization>

    <compilation debug="true" targetFramework="4.0" />
    <httpModules>
      <add name="CustomBasicAuthentication" type="Thinktecture.CustomBasicAuthentication.CustomBasicAuthenticationModule, Thinktecture.CustomBasicAuthenticationModule"/>
    </httpModules>
  </system.web>
</configuration>

I hope this will meet your needs.

arcain
  • 14,920
  • 6
  • 55
  • 75
  • Cheers, that's useful information but not really the solution I need. As I state in the question I already have basic HTTP authorisation that works, the problem is getting it to work in the same IIS application as Forms authenticated pages. – Keith Jan 18 '11 at 08:37
  • My solution requires two applications (they can be in the same app pool) to work because Forms authentication is mutually exclusive with all other authentication types, unless you roll your own mixed mode module to do both. You can only override authentication modes at an application level. So, I believe the answer to your question is that you can't do what you want to, unless you use a second application so that you can override your parent site's configuration. – arcain Jan 18 '11 at 08:56
  • We have a large number of IIS applications (something like 100 or so) running this code on the same servers - that's enough of a headache for our hosting guys as it is without doubling that. I think roll my own mixed module might be the only way, but it's an ugly solution to something that should be simple. – Keith Jan 18 '11 at 09:20
  • If the inner application want to use some resources , for ex, some EF models. How could you link those? – Duc Tran May 14 '12 at 07:03
1

This may not be the most elegant of solutions but I think it is a good start

1)Create a HttpModule.

2)handle the AuthenticateRequest event.

3)in the event handler check that the request is to the directory that you want to allow access to.

4)If it is then manually set the auth cookie: (or see if you can find another way now that you have control and authentication has not yet happened)

FormsAuthentication.SetAuthCookie("Anonymous", false);

5) Oh almost forgot, you would want to make sure the auth cookie was cleared if the request was not to the directory that you wanted to grant access to.

nixon
  • 1,952
  • 5
  • 21
  • 41
1

After looking at your comments to my previous answer, I wondered if you could have your web app automate the deployment of an application on your REST directory. That would allow you to have the benefits of a second application, and would also reduce the deployment burden on your system admins.

My thought was that you could put a routine into the Application_Start method of the global.asax that would check that the REST directory exists, and that it does not already have an application associated with it. If the test returns true, then the process of associating a new application to the REST directory occurs.

Another thought I had was that you could use WIX (or another deployment technology) to build a install package that your admins could run to create the application, however I don't think that's as automatic as having the app configure its dependency.

Below, I've included a sample implementation that checks IIS for a given directory and applies an application to it if it does not already have one. The code was tested with IIS 7, but should work on IIS 6 as well.

//This is part of global.asax.cs
//This approach may require additional user privileges to query IIS

//using System.DirectoryServices;
//using System.Runtime.InteropServices;

protected void Application_Start(object sender, EventArgs evt)
{
  const string iisRootUri = "IIS://localhost/W3SVC/1/Root";
  const string restPhysicalPath = @"C:\inetpub\wwwroot\Rest";
  const string restVirtualPath = "Rest";

  if (!Directory.Exists(restPhysicalPath))
  {
    // there is no rest path, so do nothing
    return;
  }

  using (var root = new DirectoryEntry(iisRootUri))
  {
    DirectoryEntries children = root.Children;

    try
    {
      using (DirectoryEntry rest = children.Find(restVirtualPath, root.SchemaClassName))
      {
        // the above call throws an exception if the vdir does not exist
        return;
      }
    }
    catch (COMException e)
    {
      // something got unlinked incorrectly, kill the vdir and application
      foreach (DirectoryEntry entry in children)
      {
        if (string.Compare(entry.Name, restVirtualPath, true) == 0)
        {
          entry.DeleteTree();
        }     
      }
    }
    catch (DirectoryNotFoundException e)
    {
      // the vdir and application do not exist, add them below
    }

    using (DirectoryEntry rest = children.Add(restVirtualPath, root.SchemaClassName))
    {
      rest.CommitChanges();
      rest.Properties["Path"].Value = restPhysicalPath;
      rest.Properties["AccessRead"].Add(true);
      rest.Properties["AccessScript"].Add(true);
      rest.Invoke("AppCreate2", true);
      rest.Properties["AppFriendlyName"].Add(restVirtualPath);
      rest.CommitChanges();
    }
  }
}

Portions of this code came from here. Good luck with your app!

Community
  • 1
  • 1
arcain
  • 14,920
  • 6
  • 55
  • 75