WCF 客户端服务器应用程序,带自定义身份验证、授权、加密和压缩 – 第 2 部分






4.87/5 (37投票s)
HTTP - 无 IIS;身份验证 - 无 SSL/X509 证书;加密 - 请求使用 RSA+AES,响应使用 AES;压缩 - 请求/响应均使用 GZip。
目录
本文档详细介绍了由于篇幅原因未在 第 1 部分 中处理的实现。
概述
我们有 3 个部分,将产生 3 个项目 – Client
、Server
和 Common
(服务器和客户端的通用库)。
首先,我们快速浏览一下类,并解释它们的作用——尽管我认为这已经很清楚了。
让我们从通用部分开始。ServerInfo
包含服务器的日期/时间以及公钥。Credentials
包含 UserName/Password/Expires
属性,这些属性会被序列化并发送到服务器;其他属性(关于日期/时间)是 static
属性,在开始时从服务器信息初始化,并在请求前不久用于设置 Expires
属性。
现在移到服务器的 AppServer
static
类;它的作用是创建/启动/停止服务,该服务将为客户端提供 ServerInfo
。
现在是客户端,我们有 AppClient
static
类;它的作用是获取 ServerInfo
;它还托管客户端的凭据。
身份验证
我们在客户端开始,将凭据添加到消息头。我们通过使用一个实现 IClientMessageInspector
的 BehaviorExtensionElement 来实现这一点,如下所示:
public class ClientMessageInspector : BehaviorExtensionElement,
IClientMessageInspector, IEndpointBehavior
{
public object BeforeSendRequest(ref Message request, IClientChannel channel)
{
request.Headers.Add(AppClient.Credentials.ToMessageHeader());
return null;
}
//... other methods ...
}
以及在配置文件(App.config)中:
<behaviorExtensions>
<add name="ChallengeClientMessageInspector"
type="Challenge.Client.ClientMessageInspector, Client" />
</behaviorExtensions>
接下来是编码机制,其中涉及加密和压缩,但我们稍后会讨论。
现在到服务器:我们需要从 ServiceAuthenticationManager
派生一个类,并重写 Authenticate
方法[3];在这里,如果 CheckCredentials
成功并且没有抛出任何异常,我们就从凭据中进行用户身份验证。
public class ChallengeAuthenticationManager : ServiceAuthenticationManager
{
public override ReadOnlyCollection<IAuthorizationPolicy>
Authenticate(ReadOnlyCollection<IAuthorizationPolicy> authPolicy,
Uri listenUri, ref Message message)
{
Credentials credentials = Credentials.FromMessageHeader(message);
CheckCredentials(credentials);
ChallengeIdentity identity = new ChallengeIdentity(credentials.UserName);
IPrincipal user = new ChallengePrincipal(identity);
message.Properties["Principal"] = user;
return authPolicy;
}
public void CheckCredentials(Credentials credentials)
{
if (credentials.Expires < DateTime.Now)
throw new AuthenticationException("Credentials expired!");
// check the user and password against a database;
// if not match
// throw new AuthenticationException("Incorrect credentials!");
}
//... other methods ...
}
<behavior name="ChallengeBehavior">
<serviceAuthenticationManager
serviceAuthenticationManagerType=
"Challenge.Server.ChallengeAuthenticationManager,
Server"/>
</behavior>
现在让我们解释 ChallengeIdentity
和 ChallengePrincipal
:前者实现 IIdentity,后者实现 IPrincipal。
两者都非常容易实现;但是,我将在下面重点介绍 ChallengePrincipal
,因为我们的授权就在其中。
Authorization
通过授权,我们可以确定已验证的用户中哪些有权执行操作合同,权限如下所示:
[PrincipalPermission(SecurityAction.Demand, Role = "Admin")]
public int Sum(int a, int b)
{
return a + b;
}
仅当已验证用户属于“Admin”角色时,才允许其执行上述方法——这是使用 IsInRole
方法检查的。
public class ChallengePrincipal: IPrincipal
{
IIdentity identity;
string[] roles = null;
public bool IsInRole(string role)
{
EnsureRoles();
return roles != null ? roles.Contains(role) : false;
}
protected virtual void EnsureRoles()
{
// get the roles for the identity from a database (or other source)
// and cache them for subsequent requests
// here we'll add a few roles as example
roles = new string[] { "User", "Admin", "Manager" };
}
//... other methods ...
}
为了使授权生效,我们需要创建自己的授权策略(并在配置文件中指定),如下所示:
public class ChallengeAuthorizationPolicy : IAuthorizationPolicy
{
public bool Evaluate(EvaluationContext evaluationContext, ref object state)
{
IPrincipal user = OperationContext.Current.IncomingMessageProperties
["Principal"] as IPrincipal;
evaluationContext.Properties["Principal"] = user;
evaluationContext.Properties["Identities"] = new List<IIdentity>
{ user.Identity };
return false;
}
//... other methods ...
}
<behavior name="ChallengeBehavior">
<serviceAuthorization principalPermissionMode="Custom" >
<authorizationPolicies>
<add policyType='Challenge.Server.ChallengeAuthorizationPolicy, Server' />
</authorizationPolicies>
</serviceAuthorization>
</behavior>
加密
加密/解密由通用的 Cryptographer
/ClientCryptographer
/ServerCryptographer
提供;需要为服务器和客户端提供单独的加密器,因为它们执行不同的加密/解密。
客户端 - 加密请求
- 获取消息的 ID,将其与密钥关联,并一直保存到收到服务器的响应——我们需要该密钥来解密来自服务器的响应。
- 获取要加密的元素(如果只加密凭据,则为
Credentials
元素;如果加密整个消息,则为第一个节点),并加密该元素。 - 使用服务器的
public
密钥[2] 加密 AES 密钥,并将其添加到加密节点(使用名称KeyElementName
——它对于服务器和客户端都是常量)。 - 将加密元素的 ID 设置为消息的 ID(以便在解密之前知道消息的 ID)。
- 用加密节点替换原始节点。
public static void Encrypt(XmlDocument xmlDoc, string elementToEncrypt) //[8]
{
XmlNodeList elementsToEncrypt = xmlDoc.GetElementsByTagName(elementToEncrypt);
if (elementsToEncrypt.Count == 0)
return;
AesCryptoServiceProvider aesServiceProvider =
new AesCryptoServiceProvider();
aesServiceProvider.KeySize = 256;
aesServiceProvider.GenerateKey();
XmlNode idNode = xmlDoc.GetElementsByTagName("a:MessageID")[0];
string id = idNode.InnerText;
AesKeys.Add(id, aesServiceProvider.Key);
XmlElement xmlElementToEncrypt = (XmlElement)elementsToEncrypt[0];
EncryptedXml encryptedXml = new EncryptedXml();
byte[] encryptedElement = encryptedXml.EncryptData
(xmlElementToEncrypt, aesServiceProvider, Content);
EncryptedData encryptedData = new EncryptedData();
encryptedData.Type = EncryptedXml.XmlEncElementUrl;
encryptedData.EncryptionMethod =
new EncryptionMethod(EncryptedXml.XmlEncAES256Url);
EncryptedKey encryptedKey = new EncryptedKey();
encryptedKey.CipherData = new CipherData
(EncryptedXml.EncryptKey(aesServiceProvider.Key, RsaServiceProvider, Content));
encryptedKey.EncryptionMethod =
new EncryptionMethod(EncryptedXml.XmlEncRSA15Url);
encryptedData.KeyInfo = new KeyInfo();
encryptedKey.KeyInfo.AddClause(new KeyInfoName(KeyElementName));
encryptedData.KeyInfo.AddClause(new KeyInfoEncryptedKey(encryptedKey));
encryptedData.CipherData.CipherValue = encryptedElement;
encryptedData.Id = id;
EncryptedXml.ReplaceElement(xmlElementToEncrypt, encryptedData, Content);
}
服务器 - 解密请求
- 将传入文档视为已加密的文档;
- 将
KeyElementName
(正如我之前所说,它对于客户端和服务器都是常量)与服务器的私钥关联,该私钥将用于解密客户端 AES 密钥; - 将消息的 ID 与密钥关联——这在加密响应时用于查找客户端密钥;
- 将密码添加到禁止列表中(或者,如果已存在,则抛出
SecurityException
); - 解密文档。
public static void Decrypt(XmlDocument xmlDoc) //[8]
{
XmlNodeList encryptedElements = xmlDoc.GetElementsByTagName("EncryptedData");
if (encryptedElements.Count == 0)
return;
EncryptedXml encryptedXml = new EncryptedXml(xmlDoc);
encryptedXml.AddKeyNameMapping(KeyElementName, RsaServiceProvider);
EncryptedData eData = new EncryptedData();
XmlElement encryptedElement = (XmlElement)encryptedElements[0];
eData.LoadXml(encryptedElement);
SymmetricAlgorithm a = encryptedXml.GetDecryptionKey
(eData, eData.EncryptionMethod.KeyAlgorithm);
//here the aes service provider gets the client key![6]
AesKeys.Add(eData.Id, a.Key);
string keyHash = a.Key.ComputeHash();
if (AesBannedKeys.ContainsKey(keyHash))
throw new SecurityException("Password reuse before ban expiration!");
else
AesBannedKeys.Add(keyHash, DateTime.Now.AddMilliseconds
(AesBannedKeysExpiresTimeSpan));
encryptedXml.DecryptDocument();
}
服务器 - 加密响应
- 找到要加密的元素。
- 根据消息的 ID(
RelatesTo
元素)获取在请求中保存的密钥。 - 加密消息。
- 将消息的 ID 设置为加密数据(因此客户端知道要使用哪个密钥——如果只发送同步请求,那么我们只需要使用最后一个密钥,但我们必须同时考虑两种情况)。
- 用加密的元素替换原始元素。
public static void Encrypt(XmlDocument xmlDoc, string elementToEncrypt) //[9]
{
XmlNodeList elementsToEncrypt = xmlDoc.GetElementsByTagName(elementToEncrypt);
if (elementsToEncrypt.Count == 0)
return;
AesCryptoServiceProvider aesServiceProvider = new AesCryptoServiceProvider();
XmlNode idNode = xmlDoc.GetElementsByTagName("a:RelatesTo")[0];
string id = "";
if (idNode != null)
{
id = idNode.InnerText;
}
if (AesKeys.ContainsKey(id))
{
aesServiceProvider.Key = AesKeys[id];
}
XmlElement xmlElementToEncrypt = (XmlElement)elementsToEncrypt[0];
EncryptedXml encryptedXml = new EncryptedXml();
byte[] encryptedElement = encryptedXml.EncryptData
(xmlElementToEncrypt, aesServiceProvider, Content);
EncryptedData encryptedData = new EncryptedData();
encryptedData.Type = EncryptedXml.XmlEncElementUrl;
encryptedData.EncryptionMethod = new EncryptionMethod
(EncryptedXml.XmlEncAES256Url);
encryptedData.CipherData.CipherValue = encryptedElement;
encryptedData.Id = id;
EncryptedXml.ReplaceElement(xmlElementToEncrypt, encryptedData, Content);
}
客户端 - 解密响应
- 找到加密的元素并将其加载为加密数据。
- 根据加密数据的 ID 获取 AES 算法的密钥。
- 解密加密的元素并将其替换到文档中。
public static void Decrypt(XmlDocument xmlDoc) //[9]
{
XmlNodeList encryptedElements = xmlDoc.GetElementsByTagName("EncryptedData");
if(encryptedElements.Count == 0)
return;
XmlElement encryptedElement = (XmlElement)encryptedElements[0];
EncryptedData encryptedData = new EncryptedData();
encryptedData.LoadXml(encryptedElement);
AesCryptoServiceProvider aesServiceProvider = new AesCryptoServiceProvider();
if (AesKeys.ContainsKey(encryptedData.Id))
{
aesServiceProvider.Key = AesKeys[encryptedData.Id];
}
EncryptedXml encryptedXml = new EncryptedXml();
encryptedXml.ReplaceData(encryptedElement,
encryptedXml.DecryptData(encryptedData, aesServiceProvider));
}
由于我们要在消息编码器中执行此操作,因此我们无法获取 XML 文档形式的消息,而是获取一个数组段;因此,对于客户端和服务器,我们需要对数组段执行加密(实际上是将数组段转换为 XML 文档,然后应用上述方法)。
public static ArraySegment<byte> EncryptBuffer(ArraySegment<byte> buffer,
BufferManager bufferManager, int messageOffset,
string elementToEncrypt = "s:Envelope")
{
byte[] bufferedBytes;
byte[] encryptedBytes;
XmlDocument xmlDoc = new XmlDocument();
using (MemoryStream memoryStream = new MemoryStream
(buffer.Array, buffer.Offset, buffer.Count))
{
xmlDoc.Load(memoryStream);
}
ClientCryptographer.Encrypt(xmlDoc, elementToEncrypt);
encryptedBytes = Encoding.UTF8.GetBytes(xmlDoc.OuterXml);
bufferedBytes = bufferManager.TakeBuffer(encryptedBytes.Length);
Array.Copy(encryptedBytes, 0, bufferedBytes, 0, encryptedBytes.Length);
bufferManager.ReturnBuffer(buffer.Array);
ArraySegment<byte> byteArray = new ArraySegment<byte>
(bufferedBytes, messageOffset, encryptedBytes.Length);
return byteArray;
}
创建一个新的 AesCryptoServiceProvider
[1] 并生成一个新密钥。
压缩
对于压缩/解压缩,我们将使用 Microsoft 的示例;解压缩方法是相同的;我更改了压缩,因为存在一个严重的错误[7]。
public static ArraySegment<byte> CompressBuffer
(ArraySegment<byte> buffer, BufferManager bufferManager, int messageOffset)
{
byte[] bufferedBytes, compressedBytes;
using (MemoryStream memoryStream = new MemoryStream())
{
memoryStream.Write(buffer.Array, 0, messageOffset);
using (GZipStream gzStream = new GZipStream
(memoryStream, CompressionMode.Compress, true))
{
gzStream.Write(buffer.Array, messageOffset, buffer.Count);
}
compressedBytes = memoryStream.ToArray();
bufferedBytes = bufferManager.TakeBuffer(compressedBytes.Length);
Array.Copy(compressedBytes, 0, bufferedBytes, 0, compressedBytes.Length);
bufferManager.ReturnBuffer(buffer.Array);
}
//ArraySegment<byte> byteArray = new ArraySegment<byte>
//(bufferedBytes, messageOffset, bufferedBytes.Length - messageOffset);//bug here
ArraySegment<byte> byteArray =
new ArraySegment<byte>(bufferedBytes, messageOffset, compressedBytes.Length);
return byteArray;
}
编码
仅仅通过查看附加的代码可能不容易理解这一点;因此,我将通过一些修改来展示它,使其更容易理解。
首先,我将只展示客户端的编码(没有必要展示服务器,因为它类似);其次——我将通用部分与客户端结合起来(因为实际上一切都是这样开始的——先考虑客户端,然后是服务器,之后是它们相同的ส่วน,因此“common”项目应运而生)。
首先,我们有基于 abstract
类 MessageEncoder 的 ClientEncoder
,其中最重要的方法是 WriteMessage
和 ReadMessage
;在这里,我们使用 加密 和 压缩 章节中介绍的方法;ContentCompression
和 ContentEncryption
属性由创建编码器的类初始化,实际上是配置文件中 contentCompression
和 contentEncryption
属性的传播(稍后,我们将看到如何从 App.config 中检索它)。
public class ClientMessageEncoder : MessageEncoder //[4]
{
public override ArraySegment<byte> WriteMessage(Message message,
int maxMessageSize, BufferManager bufferManager, int messageOffset)
{
ArraySegment<byte> buffer = innerEncoder.WriteMessage
(message, maxMessageSize, bufferManager, messageOffset);
switch (ContentEncryption)
{
case ContentEncryptionType.All:
{
buffer = ClientCryptographer.EncryptBuffer(buffer,
bufferManager, messageOffset);
break;
}
case ContentEncryptionType.Credentials:
{
buffer = ClientCryptographer.EncryptBuffer(buffer,
bufferManager, messageOffset,
ContentEncryptionType.Credentials.ToString());
break;
}
}
if (ContentCompression != ContentCompressionType.None)
buffer = CompressBuffer(buffer, bufferManager, messageOffset);
return buffer;
}
public override Message ReadMessage(ArraySegment<byte> buffer,
BufferManager bufferManager, string contentType)
{
ArraySegment<byte> workingBuffer = buffer;
if (ContentCompression != ContentCompressionType.None)
buffer = DecompressBuffer(buffer, bufferManager);
if (ContentEncryption != ContentEncryptionType.None)
buffer = ClientCryptographer.DecryptBuffer(buffer, bufferManager);
Message returnMessage = innerEncoder.ReadMessage(buffer, bufferManager);
returnMessage.Properties.Encoder = this;
return returnMessage;
}
//... other methods ...
}
ClientMessageEncoder
由一个工厂编码器使用:
public class ClientMessageEncoderFactory : MessageEncoderFactory //[13]
{
MessageEncoder encoder;
public ClientMessageEncoderFactory(MessageEncoderFactory messageEncoderFactory)
{
encoder = new ClientMessageEncoder(messageEncoderFactory.Encoder);
}
//... other methods ...
}
ClientMessageEncoderFactory
由绑定元素在 CreateMessageEncoderFactory
方法中使用。
public class ClientMessageEncodingBindingElement :
MessageEncodingBindingElement //[12]
{
public override IChannelFactory<TChannel>
BuildChannelFactory<TChannel>(BindingContext context)
{
context.BindingParameters.Add(this);
var property = GetProperty<XmlDictionaryReaderQuotas>(context);
property.MaxStringContentLength = Int32.MaxValue; // [14]
return context.BuildInnerChannelFactory<TChannel>();
}
public override MessageEncoderFactory CreateMessageEncoderFactory()
{
ClientMessageEncoderFactory factory =
new ClientMessageEncoderFactory
(innerBindingElement.CreateMessageEncoderFactory());
ClientMessageEncoder encoder = factory.Encoder as ClientMessageEncoder;
encoder.ContentCompression = ContentCompression;
encoder.ContentEncryption = ContentEncryption;
return factory;
}
//... other methods ...
}
并且上面的绑定元素由扩展元素使用;在这里,我们可以从配置中获取属性(例如 contentEncryption
),并从中将它们传播到消息编码器。
public class ClientMessageEncodingElement : BindingElementExtensionElement
{
[ConfigurationProperty("contentEncryption", DefaultValue = "Credentials")]
public string ContentEncryption
{
get { return (string)base["contentEncryption"];}
set { base["contentEncryption"] = value; }
}
protected override BindingElement CreateBindingElement()
{
ClientMessageEncodingBindingElement bindingElement =
new ClientMessageEncodingBindingElement();
this.ApplyConfiguration(bindingElement);
return bindingElement;
}
//... other methods ...
}
配置应如下所示:
<extensions>
<bindingElementExtensions>
<add name="ClientMessageEncoding"
type="Challenge.Client.ClientMessageEncodingElement, Client" />
</bindingElementExtensions>
</extensions>
...
<bindings>
<customBinding>
<binding name="ChallengeMessageEncoding">
<ClientMessageEncoding contentEncryption="All" contentCompression="GZip" />
<httpTransport/>
</binding>
</customBinding>
</bindings>
现在,您已经了解了“它是如何制作的”,您可以开始 使用代码,然后 实现扩展。
注释
- 该解决方案使用 .NET 4.0。
- 请忽略与源代码管理(TFS)相关的警告。
- 您可能需要重新添加一些引用:
System.Configuration
、System.ServiceModel
、System.IdentityModel
、System.Security
、System.Runtime.Serialization
,Ionic.Zip(最后一个在Common
项目中;其他随 .NET 4 一起提供)。 - 这些引用适用于文章的两个部分以及附加的代码(带有数字的注释)。
- 启动服务器需要管理员权限。
- 要使用 Microsoft 的 zip 实现并消除对 Ionic.Zip.dll 的需求,只需在 Common 项目的 Encoding.cs 中将“
using Ionic.Zlib;
”更改为“using System.IO.Compression;
”;然后您可以删除该引用和 DLL。 - 为避免重复,代码仅附加到文章的第一部分。
- 如果您想直接测试附加 zip 文件中的 server.exe 和 client.exe,您需要先取消阻止 server.exe.config 和 client.exe.config(右键单击,属性,取消阻止);否则,您将收到配置错误。
参考文献
[1] RSACryptoServiceProvider 信息[2] AesCryptoServiceProvider 信息
[3] 自定义 WCF 身份验证
[4] 自定义消息编码器:自定义文本编码器
[5] 加密助手
[6] 如何从 RSA+AES 加密的 XML 中获取 AES 加密密钥
[7] WCF GZip 压缩错误
[8] 如何:使用非对称密钥加密 XML 元素
[9] 如何:使用对称密钥加密 XML 元素
[10] 通用解码器
[11] WCF ClearUsernameBinding
[12] Microsoft 示例
[13] Microsoft 代码 - 编码器/工厂
[14] 使用 GZipEncoder 和自定义绑定解决 WCF 压缩的 XmlDictionaryReaderQuotas 错误
[15] 中间人攻击
历史
- 2011-03-08 版本 1.0.0 - 初始发布
- 2011-03-18 版本 1.1.0 - 添加了带有密码禁止列表的代码
- 2011-03-24 版本 1.1.1 - 小文本更改