具有 Asp.Net 成员身份的 ADFS 单点登录

ADFS Single Sign-On With Asp.Net Membership

这是挑战——我维护一个混合的 asp.net mvc/web 表单应用程序,它使用表单身份验证和旧的 asp.net 会员提供程序(aspnet_Users,aspnet_Membership 等等)。我们公司正在转向使用 ADFS 的单点登录。我们必须更改混合 asp.net 应用程序以使用 ADFS 进行身份验证。

我的问题是,我能否更改混合 asp.net 应用程序以使用 ADFS 进行身份验证,但继续使用现有的成员资格提供程序来处理授权?

这个计划行得通吗?我的假设是否正确?

  1. 使用 Windows Identity Foundation 4.5 被动重定向,如 link 中所述:https://docs.microsoft.com/en-us/dotnet/framework/security/how-to-build-claims-aware-aspnet-mvc-web-app-using-wif。未经身份验证的用户将自动重定向到我们的 ADFS 安全令牌服务器。

  2. 在 asp.net 网站中,从 ADFS 令牌中读取经过身份验证的用户的用户名,然后调用 FormsAuthentication.SetAuthCookie 使会员提供程序可用。这将在基页 class(对于 Web 表单)或自定义授权属性(对于 mvc 控制器,覆盖 AuthorizeCore)中完成。该调用只会针对特定用户进行一次,我将使用 Session 变量来跟踪是否已进行调用。

部分归结为这个问题:由于我们将使用 ADFS 进行身份验证,因此 asp.net 网站的 web.config 将具有 "None" 的身份验证模式并拒绝所有匿名用户。使用此 web.config 设置,对 FormsAuthentication.SetAuthCookie 的调用是否会单独启用会员提供程序?还是Membership provider要求认证模式设置为"Forms"?

如果您想知道 "why don't you just try it?",那是因为 ADFS 服务器将在几个月内不可用,但我现在负责制定开发计划。我确实知道,如果我只是采用常规 asp.net mvc 应用程序,将身份验证模式设置为 "None" 并使用正确的用户名和密码调用 Membership.ValidateUser,然后调用 FormsAuthentication.SetAuthCookie 成员资格提供者似乎工作正常,虽然 Request.IsAuthenticated 当然是错误的,所以我没有方便的方法对其进行完整测试,因为每次授权检查首先查看用户是否经过身份验证看角色。

此方法适用于 WIF,但如果您使用的是 ADFS 4.0,也可以使用 OWIN Katana 和 OpenID Connect。

OWIN 管道允许多个连接,例如this.

或者您可以使用支持 ASP.NET 成员资格的 identityserver 之类的东西,您可以将其与 ADFS 联合起来。 identityserver 将有两个按钮,用户可以选择他们可以使用哪个按钮进行身份验证。

事实证明这出奇地简单。我使用的是 Framework 4.7 和 Windows Server 2016。注意——其他版本的 Framework 和 Windows Server 有完全不同的指令。

执行以下步骤后,我成功地将 ADFS 集成到使用成员资格的 asp.net Web 应用程序中。对成员数据库的所有现有调用都有效(例如,Membership.Getuser()、Roles.GetRolesForUser())。此外,System.Threading.Thread.CurrentPrincipal.Identity 是全功能的。 IsInRole() 和 [Authorize] 属性等调用无需更改即可工作。

这不是一个详细的演练,只是对我必须在 Web 应用程序中更改的内容的粗略描述(设置 ADFS 是完全独立的事情)。

设置 ADFS 后,在指向 ADFS 的 Web 应用程序中创建一个 FederationMetadata.xml 文件。 Google 有关创建 FederationMetadata.xml 文件的说明。注意:请勿使用 Framework 3.5 Windows 身份联合实用程序来创建您的 FederationData.xml;该实用程序将更改您的 web.config 以使用已弃用的 Micorsoft.Identity 库。相反,您会想要使用 System.Identity 库。我的 FederationMetadata.xml 文件如下所示:

<?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>

在你的 web.config 中: 1. 使您的 FederationMetadata 文件可见

<location path="FederationMetadata">
    <system.web>
      <authorization>
        <allow users="*" />
      </authorization>
    </system.web>
  </location>
  1. 关闭身份验证,并拒绝所有未经授权的用户。

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

    ...

  2. 为方便起见,保留您现有的会员资格和角色提供者。在角色提供者中设置 cacheRolesInCookie="false"。角色现在由 SessionAuthenticationModule 维护。如果您在 cookie 中缓存角色,您将破坏 SessionAuthenticationModule。

  3. 添加 system.Identity 所需的应用程序设置,指向 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" />
    

    ...

  4. 将 WSFederationAuthenticationModule 和 SessionAuthenticationModule 添加到您的 system.Webserver 标记。 SO 站点中的某些错误不允许我在此处添加标签,因此我将在下面添加该标签作为评论。 WSFederationAuthenticationModule 中断 401 授权被拒绝的 HTTP 响应并重定向到 ADFS。 ADFS 颁发安全令牌。 WSFederationAuthenticationModule 使用该令牌,创建 ClaimsPrincipal,并将该 ClaimsPrincipal 存储在 cookie 中(这将浏览器标记为已通过身份验证)。在每次回发时,SessionAuthenticationModule 使用该 cookie 来重建 ClaimsPrincipal(因此,用户不需要在每次回发时使用 ADFS 重新进行身份验证)。 "IsAuthenticated" 和 "IsInRole()" 之类的调用以及 [Authorize] 标记都来自此 ClaimsPrincipal 对象。

  5. 设置您的 system.IdentityModel 标记以与您的 ADFS 通信。完整的说明超出了这个答案的范围,但这是我的标签的样子:

    <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>
    

  6. 保留指向您的会员数据库的现有连接字符串。这通常默认为 "LocalSqlServer":

      <connectionStrings>
    <clear />
    <add name="LocalSqlServer" connectionString="Server=MyServer; Database=MyMembershipDatabase; Integrated Security=SSPI;" providerName="System.Data.SqlClient" />
    

  7. 在 global.asax 的 Authenticate_Request 事件中从成员数据库加载用户角色。在我们的例子中,我们使用 Active Directory "objectGUID" 作为 AD 用户的唯一标识符;我们向会员数据库中的 dbo.aspnet_users table 添加了一列,以将 AD 用户与会员用户联系起来。从那里是一个简单的 SQL 调用,通过将 CurrentPrincipal.Identity 转换为 ClaimsIdentity,将成员数据库中的角色加载到 CurrentPrincipal.Identity 中。在下面的示例中,我添加了 3 个硬编码角色,但实际上这些将使用 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"));
            }
        }
    
  8. 在我们的应用程序中,单个 Active Directory 用户可以拥有多个成员身份。有时用户会想要更改会员身份。这很容易做到:

    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);
    }