Reputation: 3841
Here is the challenge—I maintain a hybrid asp.net mvc/web forms application which uses forms authentication and the old asp.net Membership provider (aspnet_Users, aspnet_Membership and so on). Our company is moving to single-sign-on using ADFS. We must alter the hybrid asp.net application to authenticate using ADFS.
My question is, can I alter the hybrid asp.net application to use ADFS for authentication, but keep using the existing Membership provider to handle authorization?
Will this plan work? Are my assumptions correct?
Use Windows Identity Foundation 4.5 passive redirection as described in this link: https://learn.microsoft.com/en-us/dotnet/framework/security/how-to-build-claims-aware-aspnet-mvc-web-app-using-wif. Unauthenticated users will automatically be redirected to our ADFS security token server.
In the asp.net web site, read the authenticated user’s username from the ADFS token and call FormsAuthentication.SetAuthCookie to make the Membership provider available. This would be done in a base page class (for web forms) or the custom authorize attribute (for mvc controllers, overriding AuthorizeCore). The call would only be made once for a particular user, and I will use a Session variable to track whether or not the call has been made.
In part it boils down to this question: since we will be using ADFS for authentication the web.config for the asp.net web site will have an authentication mode of "None" and deny all anonymous users. With this web.config setting, will the call to FormsAuthentication.SetAuthCookie alone enable the Membership provider? Or does the Membership provider require that the authentication mode be set to "Forms"?
In case you are wondering "why don't you just try it?", it is because the ADFS server will not be available for several months, but I'm charged with coming up with a development plan now. I do know that if I merely take a regular asp.net mvc application, set the authentication mode to "None" and make the calls to Membership.ValidateUser with the correct username and password and then to FormsAuthentication.SetAuthCookie the membership provider does seem to work properly, although Request.IsAuthenticated is of course false, so I've no convenient way to give this a full test, since every Authorization check first looks to see if the user is authenticated before looking at roles.
Upvotes: 2
Views: 3218
Reputation: 3841
This turned out to be surprisingly simple. I'm using Framework 4.7 and Windows Server 2016. Caution--other versions of the Framework and Windows Server have completely different instructions.
After following the steps below I successfully integrated ADFS into an asp.net web application that uses Membership. All of the existing calls to the Membership database work (for example, Membership.Getuser(), Roles.GetRolesForUser()). In addition, System.Threading.Thread.CurrentPrincipal.Identity is fully-functional. Calls such as IsInRole() and the [Authorize] attribute work with no change needed.
This is not a detailed walkthrough, just a rough description of what I had to change in the web application (setting up the ADFS is an entirely separate matter).
After setting up ADFS, create a FederationMetadata.xml file in the web application that points to the ADFS. Google for instructions on creating a FederationMetadata.xml file. Caution: do NOT use the Framework 3.5 Windows Identity Federation Utility to create your FederationData.xml; the utility will alter your web.config to use the deprecated Micorsoft.Identity libraries. You instead will want to use the System.Identity libraries. My FederationMetadata.xml file looks like this:
<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor ID="_ff25f54f-e839-4005-9dc5-bb598b34a50d" entityID="https://MyServer.MyCompany.com/ADFSAuthentication/" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<RoleDescriptor xsi:type="fed:ApplicationServiceType" xmlns:fed="http://docs.oasis-open.org/wsfed/federation/200706" protocolSupportEnumeration="http://docs.oasis-open.org/wsfed/federation/200706" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<fed:TargetScopes>
<wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
<wsa:Address>https://MyServer.MyCompany.com/ADFSAuthentication/</wsa:Address>
</wsa:EndpointReference>
<wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
<wsa:Address>https://MyServer.MyCompany.com/ADFSAuthentication/</wsa:Address>
</wsa:EndpointReference>
</fed:TargetScopes>
<fed:PassiveRequestorEndpoint>
<wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
<wsa:Address>https://MyServer.MyCompany.com/ADFSAuthentication/</wsa:Address>
</wsa:EndpointReference>
</fed:PassiveRequestorEndpoint>
</RoleDescriptor>
</EntityDescriptor>
In your web.config: 1. Make your FederationMetadata file visible
<location path="FederationMetadata">
<system.web>
<authorization>
<allow users="*" />
</authorization>
</system.web>
</location>
Turn authentication off, and deny all unauthorized users.
<system.web>
<authorization>
<deny users="?" />
</authorization>
<authentication mode="None" />
...
Keep your existing membership and role providers for convenience. In the role provider set cacheRolesInCookie="false". Roles are now maintained by the SessionAuthenticationModule. If you cache roles in cookies you will break SessionAuthenticationModule.
Add the appsettings needed by system.Identity, which point to the ADFS.
<appSettings>
<add key="ida:FederationMetadataLocation" value="https://adfs.MyCompany.com/federationmetadata/2007-06/FederationMetadata.xml" />
<add key="ida:Issuer" value="http://adfs.MyCompany.com/adfs/ls/" />
<add key="ida:ProviderSelection" value="productionSTS" />
<add key="ida:EnforceIssuerValidation" value="false" />
...
Add WSFederationAuthenticationModule and SessionAuthenticationModule to your system.Webserver tag. Some fault in the SO site is not letting me add the tag here, so I'll add that tag as a comment below. WSFederationAuthenticationModule interrupts a 401 authorization denied HTTP response and redirects to ADFS. ADFS issues a security token. WSFederationAuthenticationModule consumes that token, creates a ClaimsPrincipal, and stores that ClaimsPrincipal in a cookie (this marks the browser as authenticated). On every postback SessionAuthenticationModule uses that cookie to reconstruct the ClaimsPrincipal (because of this the user does not need to re-authenticate with ADFS on every postback). Calls like "IsAuthenticated" and "IsInRole()" and the [Authorize] tag all work from this ClaimsPrincipal object.
Set up your system.IdentityModel tag to communicate with your ADFS. Full instructions are beyond the scope of this answer, but here is what my tag looks like:
<system.identityModel>
<identityConfiguration>
<audienceUris>
<add value="https://MyServer.MyCompany.com/ADFSAuthentication/" />
</audienceUris>
<!--certificationValidationMode set to "None" by the the Identity and Access Tool for Visual Studio. For development purposes.-->
<certificateValidation certificateValidationMode="None" />
<issuerNameRegistry type="System.IdentityModel.Tokens.ValidatingIssuerNameRegistry, System.IdentityModel.Tokens.ValidatingIssuerNameRegistry">
<authority name="http://adfs.MyCompany.com/adfs/services/trust">
<keys>
<add thumbprint="MyGuid" />
</keys>
<validIssuers>
<add name="http://adfs.MyCompany.com/adfs/services/trust" />
</validIssuers>
</authority>
</issuerNameRegistry>
<securityTokenHandlers>
<add type="System.IdentityModel.Services.Tokens.MachineKeySessionSecurityTokenHandler, System.IdentityModel.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<remove type="System.IdentityModel.Tokens.SessionSecurityTokenHandler, System.IdentityModel, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
</securityTokenHandlers>
</identityConfiguration>
Keep your existing connection string that points to your membership database. This typically defaults to "LocalSqlServer":
<connectionStrings>
<clear />
<add name="LocalSqlServer" connectionString="Server=MyServer; Database=MyMembershipDatabase; Integrated Security=SSPI;" providerName="System.Data.SqlClient" />
Load the user's roles from the membership database in the Authenticate_Request event in the global.asax. In our case we are using the Active Directory "objectGUID" as an AD user's unique identifier; we added a column to the dbo.aspnet_users table in our membership database to tie AD users to membership users. From there it is a simple SQL call to load roles from the Membership database into the CurrentPrincipal.Identity by casting the latter as ClaimsIdentity. In the example below I am adding 3 hard-coded roles, but in fact these will be retrieved from our membership datasbase using the objectGUID.
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
var currentPrincipalIdentity = (System.Security.Claims.ClaimsIdentity)System.Threading.Thread.CurrentPrincipal.Identity;
var claims = currentPrincipalIdentity.Claims.ToList();
//if WSFederationAuthenticationModule just fired (aka user's first visit) the claims have not been loaded yet.
//if SessionAuthenticationModule just fired (aka the user has a valid security token cookie) then no need to reload the claims, they are a part of Thread.CurrentPrincipal
if (!claims.Exists(o => o.Type == "MyCompany/objectGUID_decoded"))
{
//get the encoded guid. if this does not exist exit immediately, the user has no business in our web site
var encodedGuidClaim = claims.FirstOrDefault(o => o.Type == "MyCompany/objectGUID");
if (encodedGuidClaim == null)
return;
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("MyCompany/objectGUID_decoded", new Guid(Convert.FromBase64String(encodedGuidClaim.Value)).ToString()));
//we will need a new column or table in membership database to link users to the ActiveDirectory objectGUID.
//if the user has multiple identities we will load the default (the default must exist)
//for this example I am hard-coding the MyCompany/userID guid, but in fact it will be the single or default userID guid for the user
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("MyCompany/userID", "310860D2-6329-41B7-AF44-E8DC2113B4C7"));
//for this example I am hard-coding the roles, but in face we will load the user's roles from database using the userId retrieved in the line above.
//when user changes identity then we need to write a new cookie with the new roles collection, see ExampleOfHowToChangeIdentity() above.
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "MyRole1"));
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "MyRole2"));
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "MyRole3"));
}
}
In our application a single Active Directory user can have multiple Membership identities. Sometimes users will want to change Membership identities. This is easily done:
private void ExampleOfHowToChangeIdentity(Guid newIdentity)
{
//assume that a user has multiple identies and is logged in as the default.
//the user now selects a new identity
var currentPrincipalIdentity = (System.Security.Claims.ClaimsIdentity)System.Threading.Thread.CurrentPrincipal.Identity;
var allClaims = currentPrincipalIdentity.Claims.ToList();
//first remove all of the roles from old identity
var allRoles = allClaims.Where(o => o.Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/role").ToList();
foreach (var role in allRoles)
{
currentPrincipalIdentity.RemoveClaim(role);
}
//second, fetch the new claims from the database using the newIdentity
//we will have a column or table in the Membership database that matches this guid to the UserId
//below I am hard-coding some new claims, but in fact they will be added from a database call.
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Nonesuch"));
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Nonesuch2"));
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "Nonesuch3"));
//third, replace the MyCompany/userID claim with that of the new identity
//this will always be hard-coded. this is read by Application_AuthenticateRequest each time the user visits the site
currentPrincipalIdentity.RemoveClaim(allClaims.Single(o => o.Type == "MyCompany/userID"));
currentPrincipalIdentity.AddClaim(new System.Security.Claims.Claim("MyCompany/userID", newIdentity.ToString()));
//four, create a new session security token
//cast to pass into session security token constructor
var claimsPrincipal = new System.Security.Claims.ClaimsPrincipal(currentPrincipalIdentity);
var token = new System.IdentityModel.Tokens.SessionSecurityToken(claimsPrincipal);
System.IdentityModel.Services.FederatedAuthentication.SessionAuthenticationModule.WriteSessionTokenToCookie(token);
}
Upvotes: 2
Reputation: 46700
This approach is true for WIF but you can also use OWIN Katana and OpenID Connect if you are using ADFS 4.0.
The OWIN plumbing allows multiple connections e.g. this.
Or you can use something like identityserver that does support ASP.NET membership and you can federate this with ADFS. identityserver will then have two buttons and the user can choose which one they can authenticate with.
Upvotes: 1