65.9K
CodeProject 正在变化。 阅读更多。
Home

使用自跟踪实体和 WCF 服务构建的 Silverlight 示例 - 第 4 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.60/5 (4投票s)

2011年4月12日

CPOL

10分钟阅读

viewsIcon

39953

本系列文章描述了使用自跟踪实体、WCF服务、WIF、MVVM Light工具包、MEF和T4模板创建Silverlight业务应用程序的第4部分。

  • 请访问此 项目站点 获取最新版本和源代码。

文章系列

本文是关于使用自跟踪实体、WCF服务、WIF、MVVM Light工具包、MEF和T4模板开发Silverlight业务应用程序的系列文章的最后一部分。

IssueVisionSTPart4.jpg

目录

引言

Windows Identity Foundation (WIF) 是一组用于构建身份感知应用程序的.NET Framework类。它为我们提供了丰富的API来处理身份验证、授权、自定义和任何与身份相关的任务。此外,WIF使.NET开发人员能够通过将应用程序配置为依赖于身份提供者来执行部分或全部这些功能,从而将身份验证和授权外部化。在本文的前半部分,我们将尝试实现使用WIF的登录/登出功能。之后,我们将回顾一些剩余的主题,然后完成本系列文章。

在继续之前,有两点需要澄清:首先,我们将仅从Silverlight开发者的角度涵盖使用WIF的登录/登出功能。这意味着我们不会深入介绍安全令牌服务(STS)项目*IssueVision.ST_Sts*的设置。此外,我们也不会介绍如何配置项目*IssueVision.ST.Web*以与STS项目*IssueVision.ST_Sts*协同工作。如上所述,WIF的一个优点是我们可以将身份验证和授权外包给身份提供者(项目*IssueVision.ST_Sts*),这样我们作为Silverlight开发者就可以专注于如何实现自己的业务逻辑,而将WIF相关的事宜交给WIF专家来处理。如果您对学习WIF感兴趣,我推荐Vittorio Bertocci的书"Programming Windows Identity Foundation"以及一套称为"Identity Developer Training Kit"的实践实验室。实际上,项目*IssueVision.ST_Sts*直接借鉴了培训套件中的一个示例(OutOfBrowserApplications),几乎没有进行修改。

其次,正如《Programming Windows Identity Foundation》一书中所述:目前,Silverlight 4.0中没有WIF程序集,也没有原生的声明支持功能。"Identity Developer Training Kit"中的OutOfBrowserApplications示例为Silverlight应用程序添加了类似WIF的功能,但它基本上是实验性的,并且很可能会随着Silverlight的下一个版本而改变。所以,请记住这一点。

自定义适配器模块

为了为我们的示例应用程序添加类似WIF的功能,我们需要从OutOfBrowserApplications示例中获取两个自定义适配器模块。它们是*SL.IdentityModel*和*SL.IdentityModel.Server*。

模块 SL.IdentityModel

*SL.IdentityModel*是一个包含声明对象模型的程序集,它是一个临时程序集,允许我们使用WIF编程模型的一个子集。我们示例中包含的源代码来自OutOfBrowserApplications示例,并进行了一些错误修复。例如,类ClaimsIdentitySessionManager进行了如下修改,以确保事件被正确注册和注销。

#region IApplicationService
 
public void StartService( ApplicationServiceContext context )
{
  Application.Current.Resources.Add( "ClaimsIdentitySessionManager", Current );
 
  _authenticationServiceClient = new AuthenticationServiceClient(
      new CustomBinding(
          new BinaryMessageEncodingBindingElement(),
          new HttpsTransportBindingElement()
          ), new EndpointAddress( this.AuthenticationServiceEndPoint ) );
 
  _authenticationServiceClient.SignInCompleted += AuthenticationServiceClient_GetClaimsIdentityComplete;
  _authenticationServiceClient.SignInWithIssuedTokenCompleted += AuthenticationServiceClient_SignInWithIssuedTokenCompleted;
  _authenticationServiceClient.SignOutCompleted += AuthenticationServiceClient_SignOutCompleted;
 
  this.User = new ClaimsPrincipal( new ClaimsIdentity() );
 
  if ( Current.IdentityProvider is WSFederationSecurityTokenService )
  {
    this.GetClaimsIdentityAsync();
  }
}
 
public void StopService()
{
  _authenticationServiceClient.SignInCompleted -= AuthenticationServiceClient_GetClaimsIdentityComplete;
  _authenticationServiceClient.SignInWithIssuedTokenCompleted -= AuthenticationServiceClient_SignInWithIssuedTokenCompleted;
  _authenticationServiceClient.SignOutCompleted -= AuthenticationServiceClient_SignOutCompleted;
}
 
#endregion

模块 SL.IdentityModel.Server

*SL.IdentityModel.Server*是一个被项目*IssueVision.ST.Web*引用的程序集,它包含在必要时触发身份验证的逻辑。该程序集中的主要类之一是AuthenticationService类,我们稍后将讨论如何使用它。

服务器端设置

服务器端的身份验证和授权逻辑存在于*IssueVision.ST.Web*和*IssueVision.ST_Sts*这两个项目中。让我们先从*IssueVision.ST_Sts*项目开始。

IssueVision.ST_Sts 项目设置

当用户登录时,对*IssueVision.ST_Sts*项目的调用会首先击中类*CustomUserNamePasswordTokenHandler*的*ValidateToken(SecurityToken token)*函数来验证用户名和密码。

public override ClaimsIdentityCollection ValidateToken(SecurityToken token)
{
  UseNameSecurityToken usernameToken = token as UserNameSecurityToken;
 
  if (usernameToken == null)
  {
    throw new ArgumentException("usernameToken", "The security token is not a valid username security token.");
  }
 
  using (AuthenticationEntities context = new AuthenticationEntities())
  {
    User foundUser = context.Users.FirstOrDefault(n => n.Name == usernameToken.UserName);
 
    if (foundUser != null)
    {
      // generate password hash
      string passwordHash = HashHelper.ComputeSaltedHash(usernameToken.Password, foundUser.PasswordSalt);
 
      if (string.Equals(passwordHash, foundUser.PasswordHash, StringComparison.Ordinal))
      {
        IClaimsIdentity identity = new ClaimsIdentity();
        identity.Claims.Add(new Claim(WSIdentityConstants.ClaimTypes.Name, usernameToken.UserName));
 
        return new ClaimsIdentityCollection(new IClaimsIdentity[] { identity });
      }
      else
        throw new UnauthorizedAccessException("The username/password is incorrect");
    }
    else
      throw new UnauthorizedAccessException("The username/password is incorrect");
  }
}

此函数通过查询数据库查找匹配的密码哈希来验证用户名和密码。如果未找到匹配项,则会抛出异常。否则,登录过程将继续并击中CustomSecurityTokenService类的下一个函数GetOutputClaimsIdentity()

protected override IClaimsIdentity GetOutputClaimsIdentity(IClaimsPrincipal principal, RequestSecurityToken request, Scope scope)
{
  if (null == principal)
  {
    throw new ArgumentNullException("principal");
  }
 
  ClaimsIdentity outputIdentity = new ClaimsIdentity();
 
  // Issue custom claims.
  using (AuthenticationEntities context = new AuthenticationEntities())
  {
    User foundUser = context.Users.FirstOrDefault(n => n.Name == principal.Identity.Name);
 
    if (foundUser != null)
    {
      outputIdentity.Claims.Add(new Claim(System.IdentityModel.Claims.ClaimTypes.Name, principal.Identity.Name));
      if (foundUser.UserType == "A")
      {
        outputIdentity.Claims.Add(new Claim(ClaimTypes.Role, "Admin"));
      }
      else if (foundUser.UserType == "U")
      {
        outputIdentity.Claims.Add(new Claim(ClaimTypes.Role, "User"));
      }
      return outputIdentity;
    }
    else
      throw new UnauthorizedAccessException("The username/password is incorrect");
  }
}

此函数将返回一个包含ClaimTypes.NameClaimTypes.RoleClaimsIdentity对象,所有自定义声明信息将返回到客户端。之后,下一步是调用*IssueVision.ST.Web*项目中的*AuthenticationService*类。

IssueVision.ST.Web 项目设置

为了让*IssueVision.ST.Web*项目能够与WIF协同工作,我们首先需要添加对模块*SL.IdentityModel.Server*的引用。通过添加此模块,AuthenticationService类将在服务器端可用,并且该类中的一个函数SignInWithIssuedToken()在登录过程中使用。我们稍后将对此进行讨论。

添加此新引用后,我们的下一个任务是在*Service*文件夹中添加*AuthenticationService.svc*文件。其内容如下所示:

<%@ ServiceHost Language="C#" Debug="true" 
Factory="SL.IdentityModel.Server.AuthenticationServiceServiceHostFactory" 
Service="SL.IdentityModel.Server.SL.IdentityModel.Server"
%>

Factory属性指向模块*SL.IdentityModel.Server*中的*AuthenticationServiceServiceHostFactory*类,它用于实例化*AuthenticationService*的自定义服务主机。*Service*属性从不使用,但不能留空。

服务器端设置的最后一步是配置*AuthenticationService*,使其可供Silverlight客户端访问。以下是*Web.config*文件中的相关部分。

<configuration>
  ......
  <location path="Service/AuthenticationService.svc">
    <system.web>
      <authorization>
        <allow users="*"/>
      </authorization>
    </system.web>
  </location>
  ......
  <system.serviceModel>
    <bindings>
      <customBinding>
        <binding name="AuthenticationService.customBinding">
          <binaryMessageEncoding />
          <httpTransport />
        </binding>
      </customBinding>
    </bindings>
    ......
    <services>
      <service name="AuthenticationService">
        <endpoint address=""
          binding="customBinding" bindingConfiguration="AuthenticationService.customBinding"
          contract="AuthenticationService" />
        <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
      </service>
    </services>
    ......
  </system.serviceModel>
  ......
</configuration>

<location>部分允许任何人访问*AuthenticationService*服务,因为它在没有人已进行身份验证的登录过程中被调用。其余设置只是像配置其他任何服务一样配置*AuthenticationService*服务,我们不会详细介绍。*Web.config*文件还包含一个名为*<microsoft.identityModel>*的部分。此部分与WIF相关,应由配置*IssueVision.ST_Sts*项目WIF设置的人员进行设置。

到目前为止,我们已经涵盖了Silverlight开发人员应该了解的所有服务器端设置。接下来,我们将开始介绍客户端。

客户端设置

模块*SL.IdentityModel*被引用到所有客户端项目,除了项目*IssueVision.WCFService*。在该模块中,我们的重点主要放在ClaimsIdentitySessionManager类上。

类 ClaimsIdentitySessionManager

首先,我们需要修改*IssueVision.Client*项目的*App.xaml*文件,以便我们拥有该类的一个全局实例:

 <Application.ApplicationLifetimeObjects>
  <id:ClaimsIdentitySessionManager
    ApplicationIdentifier=" "
    AuthenticationServiceEndPoint="https:///IssueVision.ST/Service/AuthenticationService.svc">
    <id:ClaimsIdentitySessionManager.IdentityProvider>
      <id:WSTrustSecurityTokenService
        Endpoint="https:///IssueVision.ST_Sts/Service.svc/IWSTrust13"
        CredentialType="Username" />
    </id:ClaimsIdentitySessionManager.IdentityProvider>
  </id:ClaimsIdentitySessionManager>
</Application.ApplicationLifetimeObjects>

ApplicationIdentifier属性用于向项目*IssueVision.ST_Sts*通信请求令牌的应用程序,而*AuthenticationServiceEndPoint*属性指向*IssueVision.ST.Web*项目内可用的*AuthenticationService*。在此ClaimsIdentitySessionManager对象中,有一个名为IdentityProvider的属性。此属性接受两个属性*Endpoint*和*CredentialType*。在登录过程中,我们需要知道使用哪种协议和凭据类型来执行用户身份验证。因此,*Endpoint*设置为使用WS-Trust,*CredentialType*设置为使用Username。

ClaimsIdentitySessionManager类中,SignInUsernameAsync(string username, string password)SignOutAsync()函数在我们的示例中用于登录和登出。当调用SignInUsernameAsync()时,它首先联系*IssueVision.ST_Sts*以检查用户名和密码是否正确。如果此身份验证步骤通过,*IssueVision.ST_Sts*将创建一个安全令牌并将其传递回ClaimsIdentitySessionManager,然后ClaimsIdentitySessionManager将通过调用*IssueVision.ST.Web*项目中的AuthenticationService继续登录过程。更具体地说,*IssueVision.ST.Web*项目中的*AuthenticationService*类的*SignInWithIssuedToken(string xmlToken)*函数将被调用,并将*IssueVision.ST_Sts*的安全令牌作为唯一参数传递。然后,此函数将验证安全令牌,如果成功,将创建会话cookie并将用户声明传回,以便它们在客户端也可用。

登出过程相对简单,它以SignOutAsync()开始。此函数将调用*AuthenticationService*类中的*SignOut()*函数,该函数将清除登录过程中创建的会话cookie。

模型类 AuthenticationModel

在讨论完ClaimsIdentitySessionManager类后,让我们来谈谈我们的模型类*AuthenticationModel*如何使用这个类。*AuthenticationModel*实现了接口IAuthenticationModel,该接口主要提供登录和登出的功能。

 public interface IAuthenticationModel : INotifyPropertyChanged
{
  void SignInAsync(string userName, string password);
  event EventHandler<SignInEventArgs> SignInCompleted;
  void SignOutAsync();
  event EventHandler<SignOutEventArgs> SignOutCompleted;
 
  Boolean IsBusy { get; }
}

AuthenticationModel类中,我们首先定义一个受保护的属性SessionManager如下:

 #region "Protected Propertes"
protected ClaimsIdentitySessionManager SessionManager
{
  get
  {
    if (_sessionManager == null)
    {
      _sessionManager = ClaimsIdentitySessionManager.Current;
 
      _sessionManager.SignInComplete += _sessionManager_SignInComplete;
      _sessionManager.SignOutComplete += _sessionManager_SignOutComplete;
    }
    return _sessionManager;
  }
}
#endregion "Protected Propertes"

SessionManager属性使我们可以访问ClaimsIdentitySessionManager类的单例对象,通过使用其SignInUsernameAsync()SignOutAsync()函数,我们可以轻松地实现IAuthenticationModel接口。

 /// <summary>
/// Authenticate a user with user name and password
/// </summary>
/// <param name="userName">user name</param>
/// <param name="password">password</param>
public void SignInAsync(string userName, string password)
{
  this.SessionManager.SignInUsernameAsync(userName, password);
  this.IsBusy = true;
}
 
/// <summary>
/// Logout
/// </summary>
public void SignOutAsync()
{
  this.SessionManager.SignOutAsync();
  this.IsBusy = true;
}
 
/// <summary>
/// Event handler for SignInComplete
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _sessionManager_SignInComplete(object sender, SignInEventArgs e)
{
  this.IsBusy = false;
  if (this.SignInCompleted != null)
    this.SignInCompleted(this, e);
}
 
/// <summary>
/// Event handler for SignOutComplete
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _sessionManager_SignOutComplete(object sender, SignOutEventArgs e)
{
  this.IsBusy = false;
  if (this.SignOutCompleted != null)
    this.SignOutCompleted(this, e);
}

ViewModel 类

模型类AuthenticationModel被ViewModel类MainPageViewModelLoginFormViewModel使用,最终为最终用户提供登录/登出功能。以下是MainPageViewModel类中SignInCompletedSignOutCompleted事件的事件处理程序:

 /// <summary>
/// Event handler for SignInCompleted
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _authenticationModel_SignInCompleted(object sender, SL.IdentityModel.Services.SignInEventArgs e)
{
  if (e.Error == null)
  {
    if (e.User != null)
    {
      this.IsLoggedIn = e.User.Identity.IsAuthenticated;
      this.IsLoggedOut = !(e.User.Identity.IsAuthenticated);
      this.IsAdmin = e.User.IsInRole(IssueVisionServiceConstant.UserTypeAdmin);
 
      if (e.User.Identity.IsAuthenticated)
      {
        this.WelcomeText = "Welcome " + e.User.Identity.Name;
        // check whether the user needs to reset profile
        this._issueVisionModel.GetCurrentUserProfileResetAsync();
      }
    }
  }
}
 
/// <summary>
/// Event handler for SignOutCompleted
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void _authenticationModel_SignOutCompleted(object sender, SignOutEventArgs e)
{
  // even if e.HasError is True, we still set logout done.
  this.IsLoggedIn = false;
  this.IsLoggedOut = true;
  this.IsAdmin = false;
  this.WelcomeText = string.Empty;
}

至此,我们结束了关于如何使用WIF实现登录/登出功能的讨论。就身份验证和授权而言,我们可以看到*SL.IdentityModel*和*SL.IdentityModel.Server*模块提供了与WCF RIA Services当前提供的功能非常相似的功能。但是,缺少的一个功能是错误消息无法从*IssueVision.ST_Sts*项目正确传播到Silverlight客户端。因此,例如,如果我们输入了错误的密码,我们总是会收到这个恼人的"The remote server returned an error: NotFound"错误消息。

IssueVisionSTPart4_NotFound.jpg

当错误消息来自*IssueVision.ST.Web*项目(而不是*IssueVision.ST_Sts*项目)时,我们可以轻松地修复类似的问题,这也是我们的下一个主题。

模块 SL.WcfExceptionHandling

创建*SL.WcfExceptionHandling*模块是为了使WCF服务异常在Silverlight客户端可用,原始想法来自这篇文章,并包含了我自己的一些改进。为了使用它,我们可以采取以下两种方法之一。

首先,您需要将*SL.WcfExceptionHandling*模块作为引用包含进来。然后,只需将*SilverlightFaultBehavior*属性添加到任何WCF服务类,如下面的示例代码所示:

[SilverlightFaultBehavior]
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
public class IssueVisionService : IIssueVisionService
{
......
}

另一种方法是添加一个行为扩展配置。当无法访问WCF服务类的源代码时,这非常有用。在我们的示例应用程序中,我们通过简单地修改*Web.config*文件来实现*PasswordResetService*类的方法,如下所示:

<system.serviceModel>
  <extensions>
    <behaviorExtensions>
      <add name="SilverlightFaultBehavior" 
      type="SL.WcfExceptionHandling.SilverlightFaultBehavior, SL.WcfExceptionHandling, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"/>
    </behaviorExtensions>
  </extensions>
  <behaviors>
    <serviceBehaviors>
      ......
      <behavior name="PasswordResetServiceBehavior">
        <serviceMetadata httpGetEnabled="true" />
        <serviceDebug includeExceptionDetailInFaults="false" />
        <SilverlightFaultBehavior />
      </behavior>
    </serviceBehaviors>
  </behaviors>
  ......
  <services>
    ......
    <service behaviorConfiguration="PasswordResetServiceBehavior"
             name="IssueVision.Service.PasswordResetService">
      <endpoint address="https:///IssueVision.ST/Service/PasswordResetService.svc"
                binding="customBinding" bindingConfiguration="PasswordResetService.customBinding"
                contract="IssueVision.Service.IPasswordResetService" />
      <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
    </service>
  </services>
</system.serviceModel>

下面的屏幕截图显示,服务器端的错误消息现在已在Silverlight客户端上正确显示。

IssueVisionSTPart4_UpdateFailed.jpg

关于在自跟踪实体中更新所有列

我们的最后一个主题是关于为什么我们在更新自跟踪实体到数据库时会更新所有列。在《Entity Framework 4.0 Recipes: A Problem-Solution Approach》一书中,有一节关于"Preventing the Update of All Columns in Self-Tracking Entities"。要实现这一点,我们需要以下两个步骤:

1) 编辑*IssueVisionModel.tt*模板文件。更改以下行:

OriginalValueMembers originalValueMembers =
    new OriginalValueMembers(allMetadataLoaded, metadataWorkspace, ef);

为以下内容:

OriginalValueMembers originalValueMembers =
    new OriginalValueMembers(false, metadataWorkspace, ef);

此步骤将OriginalValueMembers()的第一个参数更改为false,这告诉类在属性更改时应记录其原始值。

2) 编辑*IssueVisionModel.Context.tt*模板文件。更改以下行:

context.ObjectStateManager.ChangeObjectState(entity, EntityState.Modified);

为以下内容:

context.ObjectStateManager.ChangeObjectState(entity, EntityState.Unchanged);

这种仅更新修改属性的问题在于,当属性的类型为Nullable<T>(T是值类型,如Int32)且其原始值为null时,它不起作用。在*IssueVisionModel.Context.Extensions.cs*文件中,我们可以看到一个名为SetValue()的函数定义如下:

 private static void SetValue(this OriginalValueRecord record, EdmProperty edmProperty,
  object value)
{
  if (value == null)
  {
    Type entityClrType = ((PrimitiveType)edmProperty.TypeUsage.EdmType).ClrEquivalentType;
    if (entityClrType.IsValueType &&
       !(entityClrType.IsGenericType && typeof(Nullable<>) == 
         entityClrType.GetGenericTypeDefinition()))
    {
      // Skip setting null original values on non-nullable CLR types because the
      // ObjectStateEntry won't allow this
      return;
    }
  }
 
  int ordinal = record.GetOrdinal(edmProperty.Name);
  record.SetValue(ordinal, value);
}

在我们的示例应用程序中,如果我们选择任何一个原始平台ID为null的问题,并将其值更新为可用的选择之一(非null值),因为*PlatformID*的类型是Nullable<int>SetValue()函数将跳过记录原始值,最终将导致系统跳过保存更新的*PlatformID*。我们可以通过在SQL Server Profiler中捕获更新语句来轻松验证这一点,如下所示:

exec sp_executesql N'update [dbo].[Issues]
set [LastChange] = @0
where ([IssueID] = @1)
',N'@0 datetime,@1 bigint',@0='2011-04-07 22:03:43.9030000',@1=10

为了避免这个问题,我们必须更新自跟踪实体中的所有列,这意味着我们需要撤销上面描述的步骤2,但仍然保留步骤1。实际上,这正是我们在*IssueVisionModel.tt*和*IssueVisionModel.Context.tt*模板文件中实现的。

至此,我们的讨论结束了。希望本系列文章对您有所帮助,请在下方进行评分和/或留下反馈。谢谢!

参考文献

历史

  • 2011年4月 - 初始发布。
© . All rights reserved.