26
votes

I have a scenario whereby I require users to be able to authenticate against an ASP.NET MVC web application using either Windows authentication or Forms authentication. If the user is on the internal network they will use Windows authentication and if they are connecting externally they will use Forms authentication. I’ve seen quite a few people asking the question how do I configure an ASP.NET MVC web application for this, but I haven’t found a complete explanation.

Please can someone provide a detailed explanation, with code examples, on how this would be done?

Thanks.

Alan T

4

4 Answers

18
votes

This is called mixed authentication mode. Basically you cannot achieve this within a single application because in IIS once you set up Windows authentication for a virtual directory it will no longer accept users from different domains. So basically you need to have two applications, the first with Windows Authentication and the second (the main application) using Forms authentication. The first application will consist of a single address which will simply redirect to the main application by issuing an authentication ticket for the domain user.

18
votes

This can be done. Reverse the configuration, set the app/root to use Anonymous and Forms Authentication... In this way, you can configure mixed authentication within the same web application, but it is tricky. So first, configure you app for Forms Authentication with loginUrl="~/WinLogin/WinLogin2.aspx". In MVC, routing overrides authentication rules set by IIS, so need to use an aspx page, as IIS can set authentication on the file. Enable Anonymous and Forms Authentication on the root web application. Enable Windows Authentication and disable anonymous authentication in root/WinLogin directory. Add custom 401 and 401.2 error pages to redirect back to the Account/Signin URL.

This will allow any browser capable of pass-through to use windows integrated authentication to auto signin. While some devices will get prompted for credentials (like iPhone) and other devices like blackberry redirected to signin page.

This also creates a cookie explicitly adding the users roles and creates a Generic principle so that role-based authorization can be used.

in WinLogin2.aspx (in WinLogin directory under "root" web application in IIS, and configured to use Windows Authentication, Anonymous disabled, and Forms enabled (as can't turn off...note IIS will complain when you enable windows authentication, just ignore) :

var logonUser = Request.ServerVariables["LOGON_USER"];
if (!String.IsNullOrWhiteSpace(logonUser))
{
    if (logonUser.Split('\\').Length > 1)
    {
        var domain = logonUser.Split('\\')[0];
        var username = logonUser.Split('\\')[1];

        var timeout = 30;

        var encTicket = CreateTicketWithSecurityGroups(false, username, domain, timeout);

        var authCookie = new HttpCookie(".MVCAUTH", encTicket) { HttpOnly = true };
        Response.Cookies.Add(authCookie);
    }
    //else
    //{
    // this is a redirect due to returnUrl being WinLogin page, in which logonUser will no longer have domain attached
    // ignore as forms ticket should already exist
    //}

    string returnUrl = Request.QueryString["ReturnUrl"];

    if (returnUrl.IsEmpty())
    {
        Response.Redirect("~/");
    }
    else
    {
        Response.Redirect(returnUrl);
    }
}

public static string CreateTicketWithSecurityGroups(bool rememberMe, string username, string domain, int timeout)
{
    using (var context = new PrincipalContext(ContextType.Domain, domain))
    {
        using (var principal = UserPrincipal.FindByIdentity(context, IdentityType.SamAccountName, username))
        {
            var securityGroups = String.Join(";", principal.GetAuthorizationGroups());

            var ticket =
                new FormsAuthenticationTicket(1,
                                                username,
                                                DateTime.UtcNow,
                                                DateTime.UtcNow.AddMinutes(timeout),
                                                rememberMe,
                                                securityGroups,
                                                "/");

            string encTicket = FormsAuthentication.Encrypt(ticket);
            return encTicket;
        }
    }
}

In IIS 7.5, click Error Pages, set the 401 page to File path of Redirect401.htm file, with this code:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <script>
      window.location.assign('../Account/Signin');
    </script>
</head>
<body>
</body>
</html>

In AccountController...

public ActionResult SignIn()
{
    return View(new SignInModel());
}

//
// POST: /Account/SignIn
[HttpPost]
public ActionResult SignIn(SignInModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        if (Membership.ValidateUser(model.UserName, model.Password))
        {
            string encTicket = CreateTicketWithSecurityGroups(model.RememberMe,  model.UserName, model.Domain, FormsAuthentication.Timeout.Minutes);

            Response.Cookies.Add(new HttpCookie(".MVCAUTH", encTicket));

            //var returnUrl = "";
            for (var i = 0; i < Request.Cookies.Count; i++)
            {
                HttpCookie cookie = Request.Cookies[i];
                if (cookie.Name == ".MVCRETURNURL")
                {
                    returnUrl = cookie.Value;
                    break;
                }
            }

            if (returnUrl.IsEmpty())
            {
                return Redirect("~/");
            }

            return Redirect(returnUrl);
        }

        ModelState.AddModelError("Log In Failure", "The username/password combination is invalid");
    }

    return View(model);
}

//
// GET: /Account/SignOut
public ActionResult SignOut()
{
    FormsAuthentication.SignOut();

    if (Request.Cookies[".MVCRETURNURL"] != null)
    {
        var returnUrlCookie = new HttpCookie(".MVCRETURNURL") { Expires = DateTime.Now.AddDays(-1d) };
        Response.Cookies.Add(returnUrlCookie);
    }

    // Redirect back to sign in page so user can 
    //   sign in with different credentials

    return RedirectToAction("SignIn", "Account");
}

In global.asax:

protected void Application_BeginRequest(object sender, EventArgs e)
{
    try
    {
        bool cookieFound = false;

        HttpCookie authCookie = null;

        for (int i = 0; i < Request.Cookies.Count; i++)
        {
            HttpCookie cookie = Request.Cookies[i];
            if (cookie.Name == ".MVCAUTH")
            {
                cookieFound = true;
                authCookie = cookie;
                break;
            }
        }

        if (cookieFound)
        {
            // Extract the roles from the cookie, and assign to our current principal, which is attached to the HttpContext.
            FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(authCookie.Value);
            HttpContext.Current.User = new GenericPrincipal(new FormsIdentity(ticket), ticket.UserData.Split(';'));
        }
    }
    catch (Exception ex)
    {
        throw;
    }
}


protected void Application_AuthenticateRequest()
{
    var returnUrl = Request.QueryString["ReturnUrl"];
    if (!Request.IsAuthenticated && !String.IsNullOrWhiteSpace(returnUrl))
    {
        var returnUrlCookie = new HttpCookie(".MVCRETURNURL", returnUrl) {HttpOnly = true};
        Response.Cookies.Add(returnUrlCookie);
    }
}

web.config

<system.web>
  <!--<authorization>
    <deny users="?"/>
  </authorization>-->
  <authentication mode="Forms">
    <forms name=".MVCAUTH" loginUrl="~/WinLogin/WinLogin2.aspx" timeout="30" enableCrossAppRedirects="true"/>
  </authentication>
  <membership defaultProvider="AspNetActiveDirectoryMembershipProvider">
    <providers>
      <add
           name="AspNetActiveDirectoryMembershipProvider"
           type="System.Web.Security.ActiveDirectoryMembershipProvider, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
           connectionStringName="ADService" connectionProtection="Secure" enablePasswordReset="false" enableSearchMethods="true" requiresQuestionAndAnswer="true"
           applicationName="/" description="Default AD connection" requiresUniqueEmail="false" clientSearchTimeout="30" serverSearchTimeout="30"
           attributeMapPasswordQuestion="department" attributeMapPasswordAnswer="division" attributeMapEmail="mail" attributeMapUsername="sAMAccountName"
           maxInvalidPasswordAttempts="5" passwordAttemptWindow="10" passwordAnswerAttemptLockoutDuration="30" minRequiredPasswordLength="7"
           minRequiredNonalphanumericCharacters="1" />
    </providers>
  </membership>
  <machineKey decryptionKey="..." validationKey="..." />
</system.web>
<connectionStrings>
  <add name="ADService" connectionString="LDAP://SERVER:389"/>
</connectionStrings>

Credit owed to http://msdn.microsoft.com/en-us/library/ms972958.aspx

4
votes

This will probably live at the bottom of this question and never be found but I was able to implement what was described at

http://mvolo.com/iis-70-twolevel-authentication-with-forms-authentication-and-windows-authentication/

It was quite easy and trivial. Didn't require multiple applications or cookie hacks, just extending the FormsAuthModule and making some web.config changes.

0
votes

I know this is an old post - but everything lives forever on the internet!

Anyway, I had to move an old website from IIS6 to IIS8. This is a WebForms website, but I assume this very simple solution is the same.

I received the error : Unable to cast object of type 'System.Security.Principal.WindowsIdentity' to type 'System.Web.Security.FormsIdentity'.

All I did was create a new application pool for the website. When creating this, I set the Managed pipeline mode to 'Classic'. (Read more here - http://www.hanselman.com/blog/MovingOldAppsFromIIS6ToIIS8AndWhyClassicModeExists.aspx) Dont forget to set the website's application pool to the new pool you just created.